feat: frontend MVP — детальная схема связей устройств (AntV X6)

- React 18 + TypeScript strict + AntV X6 2.x + AntD 5 + Zustand
- Custom nodes: SiteNode, CrossDeviceNode, SpliceNode, DeviceNode, CardNode
- 8-слойный автолейаут, порты (left/right), линии с цветами по статусу
- Toolbar, дерево навигации, карточка объекта, таблица соединений
- Контекстные меню, легенда, drag линий/нод, создание линий из портов
- Моковые данные: 3 сайта, 10 устройств, 15 линий

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-17 22:02:25 +03:00
commit ef816cdcf4
48 changed files with 8738 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
dist-ssr
*.local
*.log
.DS_Store
.idea
.vscode
*.sw?

531
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,531 @@
# Test-X6: Детальная схема связей устройства — Архитектура
## Обзор проекта
Веб-приложение для визуализации и редактирования детальных схем связей сетевых устройств. Основано на библиотеке **AntV X6** для рендеринга интерактивных графов. Позволяет отображать сайты, устройства, карты, порты и линии связи с полной поддержкой вложенности, автоматической раскладки и многопользовательского редактирования.
---
## Технологический стек
| Слой | Технологии |
|------|-----------|
| **Frontend** | React 18, TypeScript, AntD 5, AntV X6 2.x, Vite |
| **Backend** | NestJS, TypeORM, PostgreSQL |
| **Инфраструктура** | Docker, k3s, Drone CI, Harbor |
| **Линтинг** | ESLint, Prettier, tsc strict mode |
---
## Структура монорепозитория
```
test-x6/
├── frontend/ # React-приложение
│ ├── src/
│ │ ├── api/ # API-клиент, типы запросов/ответов
│ │ ├── components/ # Общие UI-компоненты
│ │ │ ├── Layout/ # Общий layout приложения
│ │ │ ├── Toolbar/ # Панель инструментов
│ │ │ ├── SidePanel/ # Левая и правая боковые панели
│ │ │ └── ConnectionsPanel/# Нижняя панель соединений
│ │ ├── features/
│ │ │ └── schema/ # Основной модуль схемы
│ │ │ ├── graph/ # Инициализация и конфигурация X6 Graph
│ │ │ ├── nodes/ # Custom Nodes (Site, Cross, Splice, Device, Card)
│ │ │ ├── ports/ # Логика портов (контейнеры, сортировка, drag)
│ │ │ ├── edges/ # Линии (стили, группировка, перемычки)
│ │ │ ├── layout/ # Автолейаут (8 слоёв)
│ │ │ ├── context-menu/# Контекстные меню
│ │ │ ├── selection/ # Выделение (rectangle, lasso)
│ │ │ ├── locking/ # Блокировка / concurrency
│ │ │ └── export/ # Экспорт PNG, Excel
│ │ ├── hooks/ # React-хуки
│ │ ├── store/ # Zustand store
│ │ ├── types/ # TypeScript типы и интерфейсы
│ │ ├── utils/ # Утилиты
│ │ ├── constants/ # Константы (цвета, размеры, слои)
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── index.html
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── .eslintrc.cjs
│ └── package.json
├── backend/ # NestJS API
│ ├── src/
│ │ ├── modules/
│ │ │ ├── sites/ # CRUD сайтов
│ │ │ ├── devices/ # CRUD устройств (активных и пассивных)
│ │ │ ├── cards/ # CRUD карт
│ │ │ ├── ports/ # CRUD портов
│ │ │ ├── lines/ # CRUD линий связи
│ │ │ ├── schema/ # Сборка и отдача схемы, сохранение layout
│ │ │ ├── locks/ # Блокировки редактирования
│ │ │ └── export/ # Экспорт (PNG-метаданные, Excel)
│ │ ├── common/ # Guards, interceptors, filters, pipes
│ │ ├── database/ # TypeORM конфигурация, миграции
│ │ ├── app.module.ts
│ │ └── main.ts
│ ├── tsconfig.json
│ ├── .eslintrc.cjs
│ └── package.json
├── CLAUDE.md
├── ARCHITECTURE.md
└── docker-compose.yml
```
---
## Архитектура Frontend
### Граф (X6 Graph)
Центральный компонент — `SchemaCanvas`, обёртка над `@antv/x6` Graph. Инициализация:
```
SchemaCanvas
├── useGraphInit() — создание экземпляра Graph, плагины (Selection, Snapline, MiniMap, Keyboard)
├── useGraphData() — загрузка данных, маппинг в ноды/эджи X6
├── useGraphInteraction() — обработчики событий (click, dblclick, contextmenu, drag)
└── useGraphLayout() — автоматическая раскладка (8 слоёв)
```
### Custom Nodes
Каждый тип ноды — отдельный класс, наследующий `Shape.Rect` (или `Shape.HTML` для сложных случаев):
| Нода | Класс | Особенности |
|------|-------|-------------|
| **SiteNode** | `Shape.Rect` + custom markup | Контейнер с шапкой (чёрный фон, 4 строки), вложенность (embedding), динамический размер |
| **CrossDeviceNode** | `Shape.Rect` + custom attrs | Асимметричные скругления (rx/ry по углам через `<path>`), пропорция 1:3, L/S порты |
| **SpliceClosureNode** | `Shape.Rect` | Квадрат 98×98, border-radius 6px |
| **OtherDeviceNode** | `Shape.Rect` | Адаптивный размер под количество портов |
| **CardNode** | `Shape.Rect` (embedded) | Вложенный в устройство, асимметричный контур (верх/низ 1.5px, бока 5px), условное отображение |
### Система портов
```typescript
// Двухконтейнерная архитектура портов
interface PortContainerConfig {
left: PortGroup; // Левый контейнер (ЛК)
right: PortGroup; // Правый контейнер (ПК)
}
```
Логика распределения:
1. При загрузке — определение стороны на основе относительного положения связанных устройств
2. При перемещении устройства — `node:move` событие, пересчёт пересечения границ, автопереброс портов
3. Ручной drag — пользователь перетаскивает порт между ЛК и ПК
### Линии (Edges)
```typescript
// Конфигурация линии
interface EdgeConfig {
style: EdgeLineStyle; // solid, dashed, dotted (Appendix B)
color: string; // по статусу (Appendix A)
medium: 'optical' | 'copper' | 'wireless' | 'unknown';
router: 'orth' | 'normal'; // ломаная / прямая
grouped: boolean; // входит ли в группу
}
```
Группировка: при >1 линии между парой устройств — рендеринг одной линии с badge-счётчиком. Тултип по hover — разбивка по статусам.
### Автолейаут (8 слоёв)
```typescript
// Маппинг типов устройств на слои
const LAYER_MAPPING: Record<number, DeviceType[]> = {
1: ['cross_optical', 'rrl', 'wireless', 'satellite'],
2: ['tspu', 'dwdm'],
3: ['men', 'sdh', 'multiservice_platform'],
4: ['ip', 'optical_modem', 'optical_mux', 'lan_wlan'],
5: ['ran_controller', 'mgn', 'mgx', 'server', 'sorm', 'mob', 'fix'],
6: ['voip', 'xdsl', 'pdh'],
7: ['ran_base_station', 'unknown', 'video_surveillance'],
8: [], // overflow from layer 1 (>6 cross) + copper cross
};
```
Алгоритм:
1. Определить тип каждого устройства → маппинг в слой
2. Проверить overflow (слой 1, >6 кроссов → перенос в слой 8)
3. Рассчитать Y-координаты слоёв (пустые — схлопнуть)
4. Внутри слоя — расположить устройства в ряд с отступами
5. Применить позиции
### State Management (Zustand)
```
SchemaStore
├── graph: Graph | null — экземпляр X6 Graph
├── data: SchemaData — текущие данные схемы
├── mode: 'view' | 'edit' — режим работы
├── selectedElements: string[] — выделенные элементы
├── clipboard: ClipboardData | null — буфер обмена
├── displaySettings: DisplaySettings — настройки отображения
├── locks: LockInfo[] — информация о блокировках
├── portSettings: Record<string, PortSettings> — пользовательские настройки портов
└── layoutPositions: Record<string, Position> — сохранённые позиции
```
---
## Архитектура Backend
### API Endpoints
```
# Схема
GET /api/schema/:deviceId — получить детальную схему устройства
PUT /api/schema/:deviceId/layout — сохранить layout (позиции, порты, карты)
# Сайты
GET /api/sites — список сайтов (с фильтрацией)
GET /api/sites/:id — карточка сайта
POST /api/sites — создать сайт
PUT /api/sites/:id — обновить сайт
DELETE /api/sites/:id — удалить сайт
# Устройства
GET /api/devices — список устройств (фильтры: тип, сайт)
GET /api/devices/:id — карточка устройства
POST /api/devices — создать устройство
PUT /api/devices/:id — обновить устройство
DELETE /api/devices/:id — удалить устройство
POST /api/devices/:id/copy — копирование (body: { withConnections: boolean })
PUT /api/devices/:id/move-site — перенос на другой сайт
# Карты
GET /api/devices/:deviceId/cards — карты устройства
PUT /api/cards/:id/visibility — показать/скрыть карту
# Порты
GET /api/devices/:deviceId/ports — порты устройства
PUT /api/ports/order — сохранить порядок портов
PUT /api/ports/:id/settings — настройки порта (цвет, сторона)
# Линии
GET /api/lines — линии (фильтры: device, site)
GET /api/lines/:id — карточка линии
POST /api/lines — создать линию
PUT /api/lines/:id — обновить линию
DELETE /api/lines/:id — удалить линию
POST /api/lines/:id/break — разрыв линии
POST /api/lines/:id/copy — копирование линии
# Блокировки
POST /api/locks — заблокировать элементы
DELETE /api/locks/:id — разблокировать
GET /api/locks/schema/:deviceId — текущие блокировки на схеме
# Экспорт
GET /api/export/excel/:deviceId — экспорт в Excel
```
### Модель данных (TypeORM Entities)
```
┌──────────────────────┐
│ Site │
├──────────────────────┤
│ id: UUID │
│ name: string │
│ address: string │
│ erpCode: string │
│ code1C: string │
│ status: SiteStatus │
│ parentSiteId: UUID? │
│ ──────────────────── │
│ devices: Device[] │
│ childSites: Site[] │
└──────────────────────┘
│ 1:N
┌──────────────────────┐
│ Device │
├──────────────────────┤
│ id: UUID │
│ name: string │
│ networkName: string │
│ ipAddress: string │
│ marking: string │
│ id1: string │
│ id2: string │
│ type: DeviceType │
│ group: DeviceGroup │ // 'active' | 'passive' | 'cross' | 'splice'
│ category: string │ // для маппинга в слой
│ status: DeviceStatus │
│ siteId: UUID │
│ ──────────────────── │
│ cards: Card[] │
│ ports: Port[] │
│ site: Site │
└──────────────────────┘
│ 1:N
┌──────────────────────┐
│ Card │
├──────────────────────┤
│ id: UUID │
│ slotName: string │
│ networkName: string │
│ status: CardStatus │
│ visible: boolean │
│ deviceId: UUID │
│ ──────────────────── │
│ ports: Port[] │
│ device: Device │
└──────────────────────┘
│ 1:N
┌──────────────────────┐
│ Port │
├──────────────────────┤
│ id: UUID │
│ name: string │
│ slotName: string │
│ side: 'left' | 'right'│
│ sortOrder: number │
│ labelColor: string │
│ deviceId: UUID │
│ cardId: UUID? │
│ ──────────────────── │
│ device: Device │
│ card: Card? │
│ linesA: Line[] │
│ linesZ: Line[] │
└──────────────────────┘
┌──────────────────────┐
│ Line │
├──────────────────────┤
│ id: UUID │
│ name: string │
│ templateName: string │
│ status: LineStatus │
│ type: LineType │ // simple | complex
│ medium: Medium │ // optical | copper | wireless | unknown
│ lineStyle: LineStyle │ // solid | dashed | dotted
│ portAId: UUID │
│ portZId: UUID │
│ ──────────────────── │
│ portA: Port │
│ portZ: Port │
│ fibers: Fiber[] │ // для сложных линий
└──────────────────────┘
┌──────────────────────┐
│ Fiber │
├──────────────────────┤
│ id: UUID │
│ name: string │
│ status: FiberStatus │
│ lineId: UUID │
│ portAId: UUID │
│ portZId: UUID │
└──────────────────────┘
┌──────────────────────┐
│ SchemaLayout │
├──────────────────────┤
│ id: UUID │
│ userId: UUID │
│ deviceId: UUID │ // центральное устройство схемы
│ positions: jsonb │ // { nodeId: {x, y, w, h} }
│ portOrders: jsonb │ // { deviceId: { portId: sortOrder } }
│ cardVisibility: jsonb │ // { cardId: boolean }
│ settings: jsonb │ // настройки отображения
│ updatedAt: Date │
└──────────────────────┘
┌──────────────────────┐
│ EditLock │
├──────────────────────┤
│ id: UUID │
│ schemaDeviceId: UUID │ // схема которую заблокировали
│ userId: UUID │
│ userName: string │
│ lockedAt: Date │
│ expiresAt: Date │ // автоистечение через N минут
└──────────────────────┘
```
### Enums и цветовая карта
```typescript
// Статусы (для цветовой карты — Appendix A)
enum EntityStatus {
Active = 'active',
Planned = 'planned',
UnderConstruction = 'under_construction',
Reserved = 'reserved',
Faulty = 'faulty',
Decommissioned = 'decommissioned',
Unknown = 'unknown',
}
// Типы устройств (для маппинга в слои)
enum DeviceCategory {
CrossOptical = 'cross_optical',
CrossCopper = 'cross_copper',
RRL = 'rrl',
Wireless = 'wireless',
Satellite = 'satellite',
TSPU = 'tspu',
DWDM = 'dwdm',
MEN = 'men',
SDH = 'sdh',
MultiservicePlatform = 'multiservice_platform',
IP = 'ip',
OpticalModem = 'optical_modem',
OpticalMux = 'optical_mux',
LanWlan = 'lan_wlan',
RanController = 'ran_controller',
MGN = 'mgn',
MGX = 'mgx',
Server = 'server',
SORM = 'sorm',
MOB = 'mob',
FIX = 'fix',
VOIP = 'voip',
xDSL = 'xdsl',
PDH = 'pdh',
RanBaseStation = 'ran_base_station',
Unknown = 'unknown',
VideoSurveillance = 'video_surveillance',
}
// Группы устройств
enum DeviceGroup {
Active = 'active',
Passive = 'passive',
}
// Среда передачи
enum Medium {
Optical = 'optical',
Copper = 'copper',
Wireless = 'wireless',
Unknown = 'unknown',
}
```
---
## Ключевые архитектурные решения
### 1. Рендеринг нод: SVG vs HTML
- **Сайт (SiteNode):** SVG `Shape.Rect` с кастомным markup. Шапка — `<foreignObject>` для текстового переноса.
- **Кросс:** SVG `<path>` для асимметричного скругления.
- **Карты и порты:** SVG элементы, вложенные через X6 embedding (parent-child).
### 2. Вложенность (Embedding)
X6 нативно поддерживает `embedding`:
```typescript
graph.options.embedding = {
enabled: true,
findParent: 'bbox', // или кастомная функция
};
```
- Сайт → устройства: `device.setParent(site)`
- Устройство → карты: `card.setParent(device)`
- Карта → порты: `port.setParent(card)` (через X6 ports API)
### 3. Группировка линий
Реализуется на уровне рендеринга:
1. При загрузке данных — группировка линий по парам `(deviceA, deviceZ)`
2. Если count > 1 → рендеринг одной «групповой» линии с label-счётчиком
3. По клику «Разгруппировать» → замена на N отдельных линий с разбивкой по статусам
### 4. Автолейаут
Кастомный алгоритм (не dagre/elk):
1. Парсинг устройств сайта → распределение по 8 слоям
2. Расчёт геометрии слоёв (Y offset, высота по максимальному элементу)
3. Размещение устройств внутри слоя (X offset, горизонтальный ряд)
4. Схлопывание пустых слоёв
5. Позиционирование дочерних сайтов по нижней границе родителя
### 5. Блокировка / Concurrency
- При переходе в режим редактирования → `POST /api/locks` (pessimistic lock)
- Блокировка с TTL (auto-expire через 30 минут неактивности)
- Heartbeat каждые 60 секунд для продления блокировки
- Другие пользователи видят значок блокировки (overlay на нодах)
- При сохранении → `DELETE /api/locks/:id`
### 6. Сохранение состояния
- Позиции, порядок портов, видимость карт — JSON в таблице `schema_layout`
- Привязка к паре `(userId, deviceId)`у каждого пользователя свой layout
- При отсутствии сохранённого layout — применяется автолейаут
---
## Зависимости Frontend
| Пакет | Назначение |
|-------|-----------|
| `@antv/x6` | Основная библиотека графов |
| `@antv/x6-plugin-selection` | Выделение (rectangle, rubberband) |
| `@antv/x6-plugin-snapline` | Привязка к сетке |
| `@antv/x6-plugin-minimap` | Мини-карта |
| `@antv/x6-plugin-keyboard` | Горячие клавиши |
| `@antv/x6-plugin-clipboard` | Буфер обмена |
| `@antv/x6-plugin-history` | Undo/Redo |
| `@antv/x6-plugin-export` | Экспорт PNG |
| `antd` | UI-компоненты (меню, модалки, таблицы, формы) |
| `zustand` | State management |
| `axios` | HTTP-клиент |
| `xlsx` | Экспорт Excel |
---
## Фазы реализации
### Фаза 1: Каркас и базовые ноды
- Инициализация проекта (frontend + backend)
- Настройка X6 Graph с базовыми плагинами
- Custom Nodes: SiteNode, OtherDeviceNode (простые)
- Базовые Entity и CRUD (sites, devices)
- Загрузка и отображение статической схемы
### Фаза 2: Порты и линии
- Система портов (двухконтейнерная, сортировка)
- CardNode с вложенными портами
- Линии с привязкой к портам
- Стили линий по статусу и среде передачи
- CrossDeviceNode, SpliceClosureNode
### Фаза 3: Интерактивность
- Контекстные меню (6 типов)
- Drag-and-drop устройств
- Создание линий (4 способа)
- Выделение (rectangle, lasso)
- Автолейаут (8 слоёв)
### Фаза 4: Панели и UI
- Toolbar с настройками
- Левая панель (дерево навигации)
- Правая панель (карточки объектов)
- Нижняя панель соединений
### Фаза 5: Режимы работы и persistence
- Режим просмотра / редактирования
- Блокировки (concurrency)
- Сохранение layout (позиции, порты, карты)
- Копирование/вставка со связями
- Разрыв линии
### Фаза 6: Группировка, экспорт, полировка
- Группировка линий (счётчик, тултип, разгруппировка)
- Экспорт PNG и Excel
- Перемычки (self-loops)
- Snap-to-grid, MiniMap
- Тестирование, оптимизация производительности

97
CLAUDE.md Normal file
View File

@ -0,0 +1,97 @@
# Test-X6 — Детальная схема связей устройства
Визуализация и редактирование детальных схем связей сетевых устройств на базе AntV X6.
## Стек
- **Frontend:** React 18 + TypeScript + AntD 5 + AntV X6 2.x + Zustand + Vite
- **Backend:** NestJS + TypeORM + PostgreSQL
- **Линтинг:** ESLint + Prettier + tsc strict
## Структура проекта
```
test-x6/
├── frontend/ # React SPA
│ └── src/
│ ├── api/ # HTTP-клиент, типы API
│ ├── components/# Toolbar, SidePanel, ConnectionsPanel, Layout
│ ├── features/
│ │ └── schema/# Ядро: graph/, nodes/, ports/, edges/, layout/, context-menu/, locking/, export/
│ ├── hooks/ # React-хуки
│ ├── store/ # Zustand
│ ├── types/ # Общие TypeScript-типы
│ ├── constants/ # Цвета статусов, размеры нод, маппинг слоёв
│ └── utils/
├── backend/ # NestJS API
│ └── src/
│ ├── modules/ # sites, devices, cards, ports, lines, schema, locks, export
│ ├── common/ # Guards, pipes, filters, interceptors
│ └── database/ # TypeORM config, миграции
└── ARCHITECTURE.md # Детальная архитектура
```
## Ключевые документы
- `ARCHITECTURE.md` — архитектура, модель данных, API, фазы реализации
- `frontend/src/features/schema/` — ядро графа X6
## Команды
```bash
# Frontend
cd frontend && npm run dev # dev-сервер
cd frontend && npm run build # сборка
cd frontend && npm run lint # линтинг
cd frontend && npm run typecheck # проверка типов
# Backend
cd backend && npm run start:dev # dev-сервер
cd backend && npm run build # сборка
cd backend && npm run lint # линтинг
cd backend && npm run typecheck # проверка типов
cd backend && npm run migration:run # применить миграции
cd backend && npm run migration:generate # сгенерировать миграцию
```
## Правила разработки
### Общие
- Язык кода и комментариев — **английский**
- Язык документации и UI — **русский**
- Строгий TypeScript: `strict: true`, никаких `any`
- Все сущности используют UUID как первичный ключ
### Frontend
- Каждый Custom Node X6 — отдельный файл в `features/schema/nodes/`
- Логика портов (распределение по контейнерам, сортировка, drag) — в `features/schema/ports/`
- Стили линий и группировка — в `features/schema/edges/`
- Автолейаут (8 слоёв) — в `features/schema/layout/`
- State management — Zustand (не Redux, не Context)
- Компоненты AntD — для всех UI-элементов (модалки, меню, таблицы, формы)
### Backend
- NestJS модули — по одному на доменную сущность
- Все эндпоинты — REST, префикс `/api`
- Валидация — `class-validator` + `class-transformer`
- Миграции TypeORM — явные, без `synchronize: true` в production
### X6 специфика
- Вложенность: Site → Device → Card → Port (через X6 embedding)
- Порты: двухконтейнерная система (left/right), автопереброс при перемещении устройств
- Линии: два режима роутинга (orth/normal), группировка при >1 линии между устройствами
- Кроссы: пропорция 1:3, асимметричное скругление, L-порты слева, S-порты справа
- Перемычки (self-loops): линия выходит из устройства и входит обратно, не пересекая его
## Цветовая карта статусов
Статусы определяют цвет контура/заливки нод и цвет линий. Константы хранятся в `frontend/src/constants/statusColors.ts`.
## Домен
- **Сайт** — физическая площадка с устройствами, может содержать дочерние сайты
- **Устройство** — сетевое оборудование (активное: роутер, свитч; пассивное: кросс, муфта)
- **Карта** — вложенная плата/модуль внутри устройства
- **Порт** — точка подключения на устройстве или карте
- **Линия** — физическое соединение между портами (простая или сложная — с волокнами)
- **Волокно (Fiber)** — отдельное волокно внутри сложной линии

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Схема связей устройств</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4512
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@antv/x6": "^2.19.2",
"@antv/x6-plugin-clipboard": "^2.1.6",
"@antv/x6-plugin-export": "^2.1.6",
"@antv/x6-plugin-history": "^2.2.4",
"@antv/x6-plugin-keyboard": "^2.2.3",
"@antv/x6-plugin-minimap": "^2.0.7",
"@antv/x6-plugin-selection": "^2.2.2",
"@antv/x6-plugin-snapline": "^2.1.7",
"@antv/x6-plugin-transform": "^2.1.8",
"@antv/x6-react-shape": "^2.2.3",
"antd": "^6.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

26
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,26 @@
import { ConfigProvider } from 'antd';
import ruRU from 'antd/locale/ru_RU';
import { AppLayout } from './components/AppLayout.tsx';
import { Toolbar } from './components/Toolbar/Toolbar.tsx';
import { LeftPanel } from './components/SidePanel/LeftPanel.tsx';
import { RightPanel } from './components/SidePanel/RightPanel.tsx';
import { ConnectionsPanel } from './components/ConnectionsPanel/ConnectionsPanel.tsx';
import { LegendModal } from './components/Legend/LegendModal.tsx';
import { SchemaCanvas } from './features/schema/SchemaCanvas.tsx';
import { ContextMenu } from './features/schema/context-menu/ContextMenu.tsx';
export default function App() {
return (
<ConfigProvider locale={ruRU}>
<AppLayout
toolbar={<Toolbar />}
leftPanel={<LeftPanel />}
canvas={<SchemaCanvas />}
rightPanel={<RightPanel />}
bottomPanel={<ConnectionsPanel />}
/>
<ContextMenu />
<LegendModal />
</ConfigProvider>
);
}

View File

@ -0,0 +1,119 @@
import { useState, type ReactNode } from 'react';
import { Button, Tooltip } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
interface AppLayoutProps {
toolbar: ReactNode;
leftPanel: ReactNode;
canvas: ReactNode;
rightPanel: ReactNode;
bottomPanel: ReactNode;
}
export function AppLayout({
toolbar,
leftPanel,
canvas,
rightPanel,
bottomPanel,
}: AppLayoutProps) {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const leftWidth = leftCollapsed ? 0 : 240;
const rightWidth = rightCollapsed ? 0 : 280;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
{/* Toolbar */}
{toolbar}
{/* Main content */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left panel */}
<div
style={{
width: leftWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
}}
>
{!leftCollapsed && leftPanel}
</div>
{/* Left toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderRight: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={leftCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="right">
<Button
type="text"
size="small"
icon={leftCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setLeftCollapsed(!leftCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Canvas + bottom panel */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>{canvas}</div>
{bottomPanel}
</div>
{/* Right toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderLeft: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={rightCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="left">
<Button
type="text"
size="small"
icon={rightCollapsed ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={() => setRightCollapsed(!rightCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Right panel */}
<div
style={{
width: rightWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
}}
>
{!rightCollapsed && rightPanel}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,170 @@
import { useState } from 'react';
import { Table, Input, Button, Tag, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import { mockData } from '../../mock/schemaData.ts';
import type { EntityStatus, Line } from '../../types/index.ts';
const { Search } = Input;
interface ConnectionRow {
key: string;
lineName: string;
lineStatus: EntityStatus;
deviceAName: string;
portAName: string;
deviceZName: string;
portZName: string;
}
export function ConnectionsPanel() {
const visible = useSchemaStore((s) => s.connectionsPanelVisible);
const setVisible = useSchemaStore((s) => s.setConnectionsPanelVisible);
const panelData = useSchemaStore((s) => s.connectionsPanelData);
const [searchValue, setSearchValue] = useState('');
const [expanded, setExpanded] = useState(false);
if (!visible || !panelData) return null;
// Build rows from panel data
const rows: ConnectionRow[] = [];
if (panelData.line) {
// Single line mode
const line = panelData.line as Line;
const portA = panelData.portA as { name: string } | null;
const portZ = panelData.portZ as { name: string } | null;
const devA = panelData.deviceA as { name: string } | null;
const devZ = panelData.deviceZ as { name: string } | null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
} else if (panelData.lines) {
// Multiple lines mode
const lines = panelData.lines as Line[];
for (const line of lines) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
}
}
const filtered = searchValue
? rows.filter(
(r) =>
r.lineName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceAName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceZName.toLowerCase().includes(searchValue.toLowerCase()),
)
: rows;
const columns: ColumnsType<ConnectionRow> = [
{
title: 'Линия',
dataIndex: 'lineName',
key: 'lineName',
render: (name: string, record: ConnectionRow) => {
const colors = STATUS_COLORS[record.lineStatus];
return (
<Space size={4}>
<Tag color={colors.border} style={{ color: colors.text, fontSize: 10 }}>
{STATUS_LABELS[record.lineStatus]}
</Tag>
<span>{name}</span>
</Space>
);
},
},
{
title: 'Устройство A',
key: 'deviceA',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceAName} ({record.portAName})
</span>
),
},
{
title: 'Устройство Z',
key: 'deviceZ',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceZName} ({record.portZName})
</span>
),
},
];
return (
<div
style={{
height: expanded ? '60%' : '30%',
minHeight: 150,
borderTop: '1px solid #f0f0f0',
background: '#fff',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
borderBottom: '1px solid #f0f0f0',
}}
>
<span style={{ fontWeight: 600, fontSize: 13 }}>Соединения</span>
<Space size={4}>
<Search
placeholder="Поиск..."
size="small"
style={{ width: 180 }}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
<Button
size="small"
icon={expanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => setExpanded(!expanded)}
/>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => setVisible(false)}
/>
</Space>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={filtered}
columns={columns}
size="small"
pagination={false}
style={{ fontSize: 11 }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { Modal, Space, Tag } from 'antd';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import { EntityStatus, LineStyle, Medium } from '../../types/index.ts';
export function LegendModal() {
const visible = useSchemaStore((s) => s.legendVisible);
const setVisible = useSchemaStore((s) => s.setLegendVisible);
return (
<Modal
title="Легенда"
open={visible}
onCancel={() => setVisible(false)}
footer={null}
width={480}
>
<div style={{ marginBottom: 20 }}>
<h4 style={{ marginBottom: 8 }}>Цвета статусов</h4>
<Space wrap>
{Object.values(EntityStatus).map((status) => {
const colors = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
return (
<Tag
key={status}
style={{
borderColor: colors.border,
backgroundColor: colors.fill,
color: colors.text,
}}
>
{label}
</Tag>
);
})}
</Space>
</div>
<div style={{ marginBottom: 20 }}>
<h4 style={{ marginBottom: 8 }}>Типы линий</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.values(LineStyle).map((style) => {
const dasharray =
style === LineStyle.Solid
? ''
: style === LineStyle.Dashed
? '8 4'
: '2 4';
return (
<div key={style} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width={60} height={20}>
<line
x1={0}
y1={10}
x2={60}
y2={10}
stroke="#333"
strokeWidth={2}
strokeDasharray={dasharray}
/>
</svg>
<span style={{ fontSize: 12 }}>{style}</span>
</div>
);
})}
</div>
</div>
<div>
<h4 style={{ marginBottom: 8 }}>Среда передачи</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.values(Medium).map((medium) => (
<div key={medium} style={{ fontSize: 12 }}>
<strong>{medium}</strong>
{medium === Medium.Optical && ' — оптическое волокно'}
{medium === Medium.Copper && ' — медный кабель'}
{medium === Medium.Wireless && ' — беспроводная связь'}
{medium === Medium.Unknown && ' — неизвестная среда'}
</div>
))}
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,149 @@
import { useState, useMemo } from 'react';
import { Tree, Input } from 'antd';
import type { TreeDataNode } from 'antd';
import {
ApartmentOutlined,
HddOutlined,
ClusterOutlined,
} from '@ant-design/icons';
import { mockData } from '../../mock/schemaData.ts';
import { useSchemaStore } from '../../store/schemaStore.ts';
const { Search } = Input;
export function LeftPanel() {
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<string[]>(['sites', 'all-devices']);
const graph = useSchemaStore((s) => s.graph);
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
const treeData = useMemo((): TreeDataNode[] => {
const sitesTree: TreeDataNode[] = mockData.sites
.filter((s) => !s.parentSiteId)
.map((site) => {
const children: TreeDataNode[] = [];
// Add devices belonging to this site
const siteDevices = mockData.devices.filter(
(d) => d.siteId === site.id,
);
for (const device of siteDevices) {
children.push({
key: device.id,
title: device.name,
icon: <HddOutlined />,
});
}
// Add child sites
const childSites = mockData.sites.filter(
(s) => s.parentSiteId === site.id,
);
for (const childSite of childSites) {
const childDevices = mockData.devices.filter(
(d) => d.siteId === childSite.id,
);
children.push({
key: childSite.id,
title: childSite.name,
icon: <ApartmentOutlined />,
children: childDevices.map((d) => ({
key: d.id,
title: d.name,
icon: <HddOutlined />,
})),
});
}
return {
key: site.id,
title: site.name,
icon: <ApartmentOutlined />,
children,
};
});
return [
{
key: 'sites',
title: 'Сайты',
icon: <ClusterOutlined />,
children: sitesTree,
},
];
}, []);
const filteredTreeData = useMemo(() => {
if (!searchValue) return treeData;
const filterTree = (nodes: TreeDataNode[]): TreeDataNode[] => {
return nodes
.map((node) => {
const title = String(node.title ?? '');
const match = title.toLowerCase().includes(searchValue.toLowerCase());
const filteredChildren = node.children
? filterTree(node.children)
: [];
if (match || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
})
.filter(Boolean) as TreeDataNode[];
};
return filterTree(treeData);
}, [treeData, searchValue]);
const handleSelect = (selectedKeys: React.Key[]) => {
const key = selectedKeys[0] as string;
if (!key || !graph) return;
// Find the node on the graph and center on it
const cell = graph.getCellById(key);
if (cell) {
graph.centerCell(cell);
graph.select(cell);
// Set right panel data
const data = cell.getData() as Record<string, unknown> | undefined;
if (data) {
setRightPanelData(data);
}
}
};
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#fff',
borderRight: '1px solid #f0f0f0',
}}
>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0' }}>
<Search
placeholder="Поиск..."
size="small"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
<Tree
showIcon
treeData={filteredTreeData}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
onSelect={handleSelect}
blockNode
style={{ fontSize: 12 }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
import { Descriptions, Empty, Tag } from 'antd';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import type { EntityStatus } from '../../types/index.ts';
function StatusTag({ status }: { status: EntityStatus }) {
const colors = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
return (
<Tag color={colors.border} style={{ color: colors.text }}>
{label}
</Tag>
);
}
export function RightPanel() {
const data = useSchemaStore((s) => s.rightPanelData);
if (!data) {
return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
borderLeft: '1px solid #f0f0f0',
}}
>
<Empty description="Выберите объект" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
const entityType = data.entityType as string;
const status = data.status as EntityStatus;
if (entityType === 'site') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Сайт"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Адрес">{data.address as string}</Descriptions.Item>
<Descriptions.Item label="ERP">{data.erpCode as string}</Descriptions.Item>
<Descriptions.Item label="1С">{data.code1C as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'device') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Устройство"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="IP">{data.ipAddress as string}</Descriptions.Item>
<Descriptions.Item label="Маркировка">{data.marking as string}</Descriptions.Item>
<Descriptions.Item label="Группа">{data.group as string}</Descriptions.Item>
<Descriptions.Item label="Категория">{data.category as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'line') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Линия"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Среда">{data.medium as string}</Descriptions.Item>
<Descriptions.Item label="Тип линии">{data.lineStyle as string}</Descriptions.Item>
<Descriptions.Item label="Тип">{data.type as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'card') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Карта"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Слот">{data.slotName as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions title="Объект" column={1} size="small" bordered>
{Object.entries(data).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value ?? '')}
</Descriptions.Item>
))}
</Descriptions>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { Button, Slider, Space, Switch, Tooltip, message } from 'antd';
import {
ZoomInOutlined,
ZoomOutOutlined,
ExpandOutlined,
PlusOutlined,
DeleteOutlined,
ReloadOutlined,
PictureOutlined,
AppstoreOutlined,
NodeIndexOutlined,
EyeOutlined,
EditOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
export function Toolbar() {
const graph = useSchemaStore((s) => s.graph);
const mode = useSchemaStore((s) => s.mode);
const setMode = useSchemaStore((s) => s.setMode);
const displaySettings = useSchemaStore((s) => s.displaySettings);
const toggleGrid = useSchemaStore((s) => s.toggleGrid);
const toggleMinimap = useSchemaStore((s) => s.toggleMinimap);
const switchLineType = useSchemaStore((s) => s.switchLineType);
const toggleLabels = useSchemaStore((s) => s.toggleLabels);
const setLegendVisible = useSchemaStore((s) => s.setLegendVisible);
const zoom = graph ? Math.round(graph.zoom() * 100) : 100;
const handleZoomIn = () => graph?.zoom(0.1);
const handleZoomOut = () => graph?.zoom(-0.1);
const handleFit = () => graph?.zoomToFit({ padding: 40 });
const handleZoomChange = (value: number) => {
if (graph) {
graph.zoomTo(value / 100);
}
};
const handleExportPng = () => {
message.info('В разработке');
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
height: 48,
borderBottom: '1px solid #f0f0f0',
background: '#fff',
flexShrink: 0,
}}
>
{/* Left: display settings */}
<Space size="middle">
<Tooltip title="Сетка">
<Switch
size="small"
checked={displaySettings.showGrid}
onChange={toggleGrid}
checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<AppstoreOutlined />}
/>
</Tooltip>
<Tooltip title="Мини-карта">
<Switch
size="small"
checked={displaySettings.showMinimap}
onChange={toggleMinimap}
/>
</Tooltip>
<Tooltip
title={
displaySettings.lineType === 'manhattan'
? 'Ломаные линии'
: 'Прямые линии'
}
>
<Button
size="small"
icon={<NodeIndexOutlined />}
onClick={switchLineType}
type={displaySettings.lineType === 'manhattan' ? 'primary' : 'default'}
/>
</Tooltip>
<Tooltip title="Подписи">
<Switch
size="small"
checked={displaySettings.showLabels}
onChange={toggleLabels}
/>
</Tooltip>
<Tooltip title="Легенда">
<Button
size="small"
icon={<InfoCircleOutlined />}
onClick={() => setLegendVisible(true)}
/>
</Tooltip>
</Space>
{/* Center: actions */}
<Space>
<Tooltip title="Добавить объект">
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => {
if (graph) {
const cells = graph.getSelectedCells();
if (cells.length) graph.removeCells(cells);
}
}}
/>
</Tooltip>
<Tooltip title="Обновить раскладку">
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Экспорт PNG">
<Button
size="small"
icon={<PictureOutlined />}
onClick={handleExportPng}
/>
</Tooltip>
</Space>
{/* Right: zoom + mode */}
<Space size="middle">
<Space size={4}>
<Tooltip title="Уменьшить">
<Button size="small" icon={<ZoomOutOutlined />} onClick={handleZoomOut} />
</Tooltip>
<Slider
style={{ width: 100 }}
min={10}
max={300}
value={zoom}
onChange={handleZoomChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
<Tooltip title="Увеличить">
<Button size="small" icon={<ZoomInOutlined />} onClick={handleZoomIn} />
</Tooltip>
<Tooltip title="Уместить на экран">
<Button size="small" icon={<ExpandOutlined />} onClick={handleFit} />
</Tooltip>
</Space>
<Tooltip title={mode === 'view' ? 'Режим просмотра' : 'Режим редактирования'}>
<Button
size="small"
type={mode === 'edit' ? 'primary' : 'default'}
icon={mode === 'view' ? <EyeOutlined /> : <EditOutlined />}
onClick={() => setMode(mode === 'view' ? 'edit' : 'view')}
>
{mode === 'view' ? 'Просмотр' : 'Редактирование'}
</Button>
</Tooltip>
</Space>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { DeviceCategory } from '../types/index.ts';
export const LAYER_MAPPING: Record<number, DeviceCategory[]> = {
1: [
DeviceCategory.CrossOptical,
DeviceCategory.RRL,
DeviceCategory.Wireless,
DeviceCategory.Satellite,
],
2: [DeviceCategory.TSPU, DeviceCategory.DWDM],
3: [
DeviceCategory.MEN,
DeviceCategory.SDH,
DeviceCategory.MultiservicePlatform,
],
4: [
DeviceCategory.IP,
DeviceCategory.OpticalModem,
DeviceCategory.OpticalMux,
DeviceCategory.LanWlan,
],
5: [
DeviceCategory.RanController,
DeviceCategory.MGN,
DeviceCategory.MGX,
DeviceCategory.Server,
DeviceCategory.SORM,
DeviceCategory.MOB,
DeviceCategory.FIX,
],
6: [DeviceCategory.VOIP, DeviceCategory.xDSL, DeviceCategory.PDH],
7: [
DeviceCategory.RanBaseStation,
DeviceCategory.Unknown,
DeviceCategory.VideoSurveillance,
],
8: [DeviceCategory.CrossCopper],
};
export const MAX_CROSS_PER_LAYER = 6;

View File

@ -0,0 +1,29 @@
import { LineStyle, Medium } from '../types/index.ts';
export interface LineVisualStyle {
strokeDasharray: string;
strokeWidth: number;
}
const LINE_STYLE_MAP: Record<LineStyle, string> = {
[LineStyle.Solid]: '',
[LineStyle.Dashed]: '8 4',
[LineStyle.Dotted]: '2 4',
};
const MEDIUM_WIDTH_MAP: Record<Medium, number> = {
[Medium.Optical]: 2,
[Medium.Copper]: 1.5,
[Medium.Wireless]: 1.5,
[Medium.Unknown]: 1,
};
export function getLineVisualStyle(
lineStyle: LineStyle,
medium: Medium,
): LineVisualStyle {
return {
strokeDasharray: LINE_STYLE_MAP[lineStyle],
strokeWidth: MEDIUM_WIDTH_MAP[medium],
};
}

View File

@ -0,0 +1,24 @@
export const SITE_HEADER_HEIGHT = 80;
export const SITE_PADDING = 30;
export const SITE_MIN_WIDTH = 400;
export const SITE_MIN_HEIGHT = 200;
export const CROSS_WIDTH = 115;
export const CROSS_HEIGHT = 345;
export const CROSS_BORDER_RADIUS = 28;
export const SPLICE_SIZE = 98;
export const SPLICE_BORDER_RADIUS = 6;
export const DEVICE_MIN_WIDTH = 140;
export const DEVICE_MIN_HEIGHT = 80;
export const DEVICE_BORDER_RADIUS = 6;
export const CARD_WIDTH = 100;
export const CARD_HEIGHT = 40;
export const PORT_RADIUS = 6;
export const LAYER_GAP = 40;
export const DEVICE_GAP = 30;
export const LAYER_PADDING_X = 20;

View File

@ -0,0 +1,55 @@
import { EntityStatus } from '../types/index.ts';
export interface StatusColorSet {
border: string;
fill: string;
text: string;
}
export const STATUS_COLORS: Record<EntityStatus, StatusColorSet> = {
[EntityStatus.Active]: {
border: '#52c41a',
fill: '#f6ffed',
text: '#389e0d',
},
[EntityStatus.Planned]: {
border: '#1890ff',
fill: '#e6f7ff',
text: '#096dd9',
},
[EntityStatus.UnderConstruction]: {
border: '#faad14',
fill: '#fffbe6',
text: '#d48806',
},
[EntityStatus.Reserved]: {
border: '#722ed1',
fill: '#f9f0ff',
text: '#531dab',
},
[EntityStatus.Faulty]: {
border: '#ff4d4f',
fill: '#fff2f0',
text: '#cf1322',
},
[EntityStatus.Decommissioned]: {
border: '#8c8c8c',
fill: '#fafafa',
text: '#595959',
},
[EntityStatus.Unknown]: {
border: '#bfbfbf',
fill: '#fafafa',
text: '#8c8c8c',
},
};
export const STATUS_LABELS: Record<EntityStatus, string> = {
[EntityStatus.Active]: 'Активный',
[EntityStatus.Planned]: 'Планируемый',
[EntityStatus.UnderConstruction]: 'Строится',
[EntityStatus.Reserved]: 'Резерв',
[EntityStatus.Faulty]: 'Неисправный',
[EntityStatus.Decommissioned]: 'Выведен',
[EntityStatus.Unknown]: 'Неизвестно',
};

View File

@ -0,0 +1,228 @@
import { useEffect, useRef } from 'react';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { initGraph } from './graph/initGraph.ts';
import { registerAllNodes } from './graph/registerNodes.ts';
import { buildGraphData } from './helpers/dataMapper.ts';
import { mockData } from '../../mock/schemaData.ts';
import { DeviceGroup } from '../../types/index.ts';
let nodesRegistered = false;
export function SchemaCanvas() {
const containerRef = useRef<HTMLDivElement>(null);
const minimapRef = useRef<HTMLDivElement>(null);
const setGraph = useSchemaStore((state) => state.setGraph);
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
const setRightPanelData = useSchemaStore((state) => state.setRightPanelData);
const setConnectionsPanelData = useSchemaStore(
(state) => state.setConnectionsPanelData,
);
const setConnectionsPanelVisible = useSchemaStore(
(state) => state.setConnectionsPanelVisible,
);
const displaySettings = useSchemaStore((state) => state.displaySettings);
useEffect(() => {
if (!containerRef.current) return;
if (!nodesRegistered) {
registerAllNodes();
nodesRegistered = true;
}
const graph = initGraph(containerRef.current, minimapRef.current);
setGraph(graph);
const { nodes, edges } = buildGraphData(mockData, displaySettings.lineType);
// Add nodes first (sites, then devices, then cards)
const siteNodes = nodes.filter((n) => n.shape === 'site-node');
const deviceNodes = nodes.filter(
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
);
const cardNodes = nodes.filter((n) => n.shape === 'card-node');
for (const node of siteNodes) {
graph.addNode(node);
}
for (const node of deviceNodes) {
const graphNode = graph.addNode(node);
// Set parent (embed in site)
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
for (const node of cardNodes) {
const graphNode = graph.addNode(node);
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
// Add edges
for (const edge of edges) {
graph.addEdge(edge);
}
// Center content
graph.centerContent();
// Event handlers
graph.on('node:click', ({ node }) => {
const data = node.getData() as Record<string, unknown>;
setRightPanelData(data);
});
graph.on('edge:click', ({ edge }) => {
const data = edge.getData() as Record<string, unknown>;
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
setRightPanelData({
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
medium: line.medium,
lineStyle: line.lineStyle,
type: line.type,
});
} else if (data) {
setRightPanelData(data);
}
});
graph.on('node:contextmenu', ({ e, node }) => {
e.preventDefault();
const data = node.getData() as Record<string, unknown>;
const entityType = data.entityType as string;
let menuType: 'site' | 'active-device' | 'passive-device' = 'active-device';
if (entityType === 'site') {
menuType = 'site';
} else if (entityType === 'device') {
const group = data.group as string;
menuType = group === DeviceGroup.Passive ? 'passive-device' : 'active-device';
}
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: menuType,
data: data,
});
});
graph.on('edge:contextmenu', ({ e, edge }) => {
e.preventDefault();
const line = mockData.lines.find((l) => l.id === edge.id);
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'line',
data: line
? {
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
}
: {},
});
});
graph.on('blank:contextmenu', ({ e }) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'blank',
data: {},
});
});
graph.on('blank:click', () => {
setContextMenu(null);
setRightPanelData(null);
});
// Port side recalculation on node move
graph.on('node:moved', () => {
// In a full implementation, we'd recalculate port sides here
});
// Show connections panel on edge double click
graph.on('edge:dblclick', ({ edge }) => {
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA
? mockData.devices.find((d) => d.id === portA.deviceId)
: null;
const devZ = portZ
? mockData.devices.find((d) => d.id === portZ.deviceId)
: null;
setConnectionsPanelData({
line,
portA,
portZ,
deviceA: devA,
deviceZ: devZ,
});
setConnectionsPanelVisible(true);
}
});
return () => {
graph.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync display settings
useEffect(() => {
const graph = useSchemaStore.getState().graph;
if (!graph) return;
if (displaySettings.showGrid) {
graph.showGrid();
} else {
graph.hideGrid();
}
}, [displaySettings.showGrid]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div
ref={minimapRef}
style={{
position: 'absolute',
bottom: 16,
right: 16,
width: 200,
height: 150,
border: '1px solid #d9d9d9',
background: '#fff',
borderRadius: 4,
overflow: 'hidden',
display: displaySettings.showMinimap ? 'block' : 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
</div>
);
}

View File

@ -0,0 +1,139 @@
import { useEffect, useRef } from 'react';
import { Dropdown, message } from 'antd';
import type { MenuProps } from 'antd';
import { useContextMenu } from '../../../hooks/useContextMenu.ts';
import { useSchemaStore } from '../../../store/schemaStore.ts';
import { mockData } from '../../../mock/schemaData.ts';
export function ContextMenu() {
const { contextMenu, hideMenu } = useContextMenu();
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
const setConnectionsPanelVisible = useSchemaStore(
(s) => s.setConnectionsPanelVisible,
);
const setConnectionsPanelData = useSchemaStore(
(s) => s.setConnectionsPanelData,
);
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = () => hideMenu();
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [hideMenu]);
if (!contextMenu?.visible) return null;
const wip = () => message.info('В разработке');
const siteMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
{ key: 'edit', label: 'Редактировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const activeDeviceMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const deviceId = contextMenu.data.entityId as string;
const deviceLines = mockData.lines.filter((l) => {
const portA = mockData.ports.find((p) => p.id === l.portAId);
const portZ = mockData.ports.find((p) => p.id === l.portZId);
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
});
setConnectionsPanelData({ lines: deviceLines, deviceId });
setConnectionsPanelVisible(true);
hideMenu();
}},
{ key: 'create-line', label: 'Создать линию', onClick: wip },
{ key: 'copy', label: 'Копировать', onClick: wip },
{ key: 'move', label: 'Переместить на другой сайт', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const passiveDeviceMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const deviceId = contextMenu.data.entityId as string;
const deviceLines = mockData.lines.filter((l) => {
const portA = mockData.ports.find((p) => p.id === l.portAId);
const portZ = mockData.ports.find((p) => p.id === l.portZId);
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
});
setConnectionsPanelData({ lines: deviceLines, deviceId });
setConnectionsPanelVisible(true);
hideMenu();
}},
{ key: 'copy', label: 'Копировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const lineMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const lineId = contextMenu.data.entityId as string;
const line = mockData.lines.find((l) => l.id === lineId);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
setConnectionsPanelData({ line, portA, portZ, deviceA: devA, deviceZ: devZ });
setConnectionsPanelVisible(true);
}
hideMenu();
}},
{ key: 'break', label: 'Разорвать линию', onClick: wip },
{ key: 'copy', label: 'Копировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const blankMenu: MenuProps['items'] = [
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
{ key: 'create-line', label: 'Создать линию', onClick: wip },
{ key: 'paste', label: 'Вставить', onClick: wip },
{ type: 'divider' },
{ key: 'fit', label: 'Уместить на экран', onClick: () => {
const graph = useSchemaStore.getState().graph;
graph?.zoomToFit({ padding: 40 });
hideMenu();
}},
];
const menuMap: Record<string, MenuProps['items']> = {
'site': siteMenu,
'active-device': activeDeviceMenu,
'passive-device': passiveDeviceMenu,
'line': lineMenu,
'line-group': lineMenu,
'blank': blankMenu,
};
const items = menuMap[contextMenu.type] ?? blankMenu;
return (
<div
ref={triggerRef}
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
>
<Dropdown
menu={{ items }}
open={true}
onOpenChange={(open) => { if (!open) hideMenu(); }}
trigger={['contextMenu']}
>
<div style={{ width: 1, height: 1 }} />
</Dropdown>
</div>
);
}

View File

@ -0,0 +1,76 @@
import type { EntityStatus } from '../../../types/index.ts';
import { Medium, LineStyle } from '../../../types/index.ts';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { getLineVisualStyle } from '../../../constants/lineStyles.ts';
import type { GraphEdgeConfig } from '../../../types/graph.ts';
export function createEdgeConfig(
id: string,
sourceCell: string,
sourcePort: string,
targetCell: string,
targetPort: string,
status: EntityStatus,
medium: Medium,
lineStyle: LineStyle,
label: string,
routerType: 'manhattan' | 'normal' = 'manhattan',
): GraphEdgeConfig {
const colors = STATUS_COLORS[status];
const visualStyle = getLineVisualStyle(lineStyle, medium);
const router =
routerType === 'manhattan'
? { name: 'manhattan' as const, args: { padding: 20 } }
: { name: 'normal' as const };
return {
id,
source: { cell: sourceCell, port: sourcePort },
target: { cell: targetCell, port: targetPort },
router,
connector: { name: 'rounded', args: { radius: 8 } },
attrs: {
line: {
stroke: colors.border,
strokeWidth: visualStyle.strokeWidth,
strokeDasharray: visualStyle.strokeDasharray || undefined,
targetMarker: null,
sourceMarker: null,
},
wrap: {
fill: 'none',
stroke: 'transparent',
strokeWidth: 20,
},
},
labels: label
? [
{
attrs: {
label: {
text: label,
fontSize: 9,
fill: '#595959',
textAnchor: 'middle',
textVerticalAnchor: 'middle',
},
rect: {
ref: 'label',
fill: '#fff',
rx: 3,
ry: 3,
refWidth: '140%',
refHeight: '140%',
refX: '-20%',
refY: '-20%',
},
},
position: { distance: 0.5 },
},
]
: [],
data: { status, medium, lineStyle, label },
zIndex: 5,
};
}

View File

@ -0,0 +1,76 @@
import type { Line, Port, Device, SchemaData } from '../../../types/index.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../../constants/statusColors.ts';
export interface LineGroup {
key: string;
deviceAId: string;
deviceZId: string;
lines: Line[];
representativeLine: Line;
count: number;
}
function getDeviceIdForPort(
portId: string,
data: SchemaData,
): string | null {
const port = data.ports.find((p: Port) => p.id === portId);
return port ? port.deviceId : null;
}
export function groupLinesByDevicePair(data: SchemaData): LineGroup[] {
const groupMap = new Map<string, Line[]>();
for (const line of data.lines) {
const devA = getDeviceIdForPort(line.portAId, data);
const devZ = getDeviceIdForPort(line.portZId, data);
if (!devA || !devZ) continue;
const key = [devA, devZ].sort().join('::');
const existing = groupMap.get(key);
if (existing) {
existing.push(line);
} else {
groupMap.set(key, [line]);
}
}
const groups: LineGroup[] = [];
for (const [key, lines] of groupMap.entries()) {
const [deviceAId, deviceZId] = key.split('::');
groups.push({
key,
deviceAId,
deviceZId,
lines,
representativeLine: lines[0],
count: lines.length,
});
}
return groups;
}
export function getGroupTooltip(
group: LineGroup,
devices: Device[],
): string {
const devA = devices.find((d) => d.id === group.deviceAId);
const devZ = devices.find((d) => d.id === group.deviceZId);
const statusCounts = new Map<string, number>();
for (const line of group.lines) {
const current = statusCounts.get(line.status) ?? 0;
statusCounts.set(line.status, current + 1);
}
let tooltip = `${devA?.name ?? '?'}${devZ?.name ?? '?'}\n`;
tooltip += `Линий: ${group.count}\n`;
for (const [status, count] of statusCounts.entries()) {
const color = STATUS_COLORS[status as keyof typeof STATUS_COLORS];
const label = STATUS_LABELS[status as keyof typeof STATUS_LABELS];
tooltip += ` ${label}: ${count} (${color.border})\n`;
}
return tooltip;
}

View File

@ -0,0 +1,207 @@
import { Graph } from '@antv/x6';
import { Selection } from '@antv/x6-plugin-selection';
import { Snapline } from '@antv/x6-plugin-snapline';
import { MiniMap } from '@antv/x6-plugin-minimap';
import { Keyboard } from '@antv/x6-plugin-keyboard';
import { Clipboard } from '@antv/x6-plugin-clipboard';
import { History } from '@antv/x6-plugin-history';
export function initGraph(
container: HTMLDivElement,
minimapContainer: HTMLDivElement | null,
): Graph {
const graph = new Graph({
container,
autoResize: true,
background: { color: '#f8f9fa' },
grid: {
visible: true,
size: 10,
type: 'dot',
args: { color: '#d9d9d9', thickness: 1 },
},
panning: { enabled: true, eventTypes: ['rightMouseDown'] },
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
minScale: 0.1,
maxScale: 3,
},
connecting: {
snap: { radius: 30 },
allowBlank: false,
allowLoop: true,
allowNode: false,
allowEdge: false,
allowMulti: true,
allowPort: true,
highlight: true,
router: { name: 'manhattan' },
connector: { name: 'rounded', args: { radius: 8 } },
connectionPoint: 'anchor',
anchor: 'center',
sourceAnchor: 'center',
targetAnchor: 'center',
createEdge() {
return this.createEdge({
attrs: {
line: {
stroke: '#52c41a',
strokeWidth: 2,
targetMarker: null,
sourceMarker: null,
},
},
router: { name: 'manhattan' },
connector: { name: 'rounded', args: { radius: 8 } },
});
},
validateConnection({ sourcePort, targetPort, sourceMagnet, targetMagnet }) {
if (!sourceMagnet || !targetMagnet) return false;
if (!sourcePort || !targetPort) return false;
return sourcePort !== targetPort;
},
},
embedding: {
enabled: true,
findParent({ node }) {
// Only site nodes can be parents
const bbox = node.getBBox();
return this.getNodes().filter((n) => {
if (n.id === node.id) return false;
const data = n.getData<{ entityType?: string }>();
if (data?.entityType !== 'site') return false;
return n.getBBox().containsRect(bbox);
});
},
},
interacting: {
nodeMovable: true,
edgeMovable: true,
edgeLabelMovable: true,
vertexMovable: true,
vertexAddable: true,
vertexDeletable: true,
},
highlighting: {
magnetAvailable: {
name: 'stroke',
args: { attrs: { fill: '#5F95FF', stroke: '#5F95FF' } },
},
},
});
// Show tools on edge hover: segments, vertices, and arrowheads for reconnecting
graph.on('edge:mouseenter', ({ edge }) => {
edge.addTools([
{
name: 'segments',
args: {
snapRadius: 20,
attrs: {
fill: '#444',
width: 20,
height: 8,
rx: 4,
ry: 4,
},
},
},
{
name: 'vertices',
args: {
attrs: {
r: 5,
fill: '#fff',
stroke: '#333',
strokeWidth: 1,
cursor: 'move',
},
snapRadius: 20,
},
},
{
name: 'source-arrowhead',
args: {
attrs: {
d: 'M 0 -5 L 10 0 L 0 5 Z',
fill: '#333',
stroke: '#fff',
strokeWidth: 1,
cursor: 'move',
},
},
},
{
name: 'target-arrowhead',
args: {
attrs: {
d: 'M 0 -5 L 10 0 L 0 5 Z',
fill: '#333',
stroke: '#fff',
strokeWidth: 1,
cursor: 'move',
},
},
},
]);
});
graph.on('edge:mouseleave', ({ edge }) => {
// Don't remove tools if edge is being dragged
if (edge.hasTool('source-arrowhead') || edge.hasTool('target-arrowhead')) {
// Delay removal to avoid removing during drag
setTimeout(() => {
if (!document.querySelector('.x6-widget-transform')) {
edge.removeTools();
}
}, 200);
} else {
edge.removeTools();
}
});
graph.use(
new Selection({
enabled: true,
multiple: true,
rubberband: true,
movable: true,
showNodeSelectionBox: true,
}),
);
graph.use(new Snapline({ enabled: true }));
if (minimapContainer) {
graph.use(
new MiniMap({
container: minimapContainer,
width: 200,
height: 150,
padding: 10,
}),
);
}
graph.use(new Keyboard({ enabled: true }));
graph.use(new Clipboard({ enabled: true }));
graph.use(new History({ enabled: true }));
// Keyboard shortcuts
graph.bindKey('ctrl+z', () => graph.undo());
graph.bindKey('ctrl+shift+z', () => graph.redo());
graph.bindKey('ctrl+c', () => graph.copy(graph.getSelectedCells()));
graph.bindKey('ctrl+v', () => graph.paste());
graph.bindKey('delete', () => {
const cells = graph.getSelectedCells();
if (cells.length) graph.removeCells(cells);
});
graph.bindKey('ctrl+a', () => {
const cells = graph.getCells();
graph.select(cells);
});
return graph;
}

View File

@ -0,0 +1,64 @@
import { register } from '@antv/x6-react-shape';
import { SiteNode } from '../nodes/SiteNode.tsx';
import { CrossDeviceNode } from '../nodes/CrossDeviceNode.tsx';
import { SpliceNode } from '../nodes/SpliceNode.tsx';
import { DeviceNode } from '../nodes/DeviceNode.tsx';
import { CardNode } from '../nodes/CardNode.tsx';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
SPLICE_SIZE,
DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT,
CARD_WIDTH,
CARD_HEIGHT,
SITE_MIN_WIDTH,
SITE_MIN_HEIGHT,
} from '../../../constants/sizes.ts';
import { portGroups } from '../ports/portConfig.ts';
export function registerAllNodes(): void {
register({
shape: 'site-node',
width: SITE_MIN_WIDTH,
height: SITE_MIN_HEIGHT,
component: SiteNode,
effect: ['data'],
});
register({
shape: 'cross-device-node',
width: CROSS_WIDTH,
height: CROSS_HEIGHT,
component: CrossDeviceNode,
ports: { groups: portGroups },
effect: ['data'],
});
register({
shape: 'splice-node',
width: SPLICE_SIZE,
height: SPLICE_SIZE,
component: SpliceNode,
ports: { groups: portGroups },
effect: ['data'],
});
register({
shape: 'device-node',
width: DEVICE_MIN_WIDTH,
height: DEVICE_MIN_HEIGHT,
component: DeviceNode,
ports: { groups: portGroups },
effect: ['data'],
});
register({
shape: 'card-node',
width: CARD_WIDTH,
height: CARD_HEIGHT,
component: CardNode,
ports: { groups: portGroups },
effect: ['data'],
});
}

View File

@ -0,0 +1,283 @@
import type { SchemaData } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
import type { GraphNodeConfig, GraphEdgeConfig, GraphBuildResult } from '../../../types/graph.ts';
import { createPortItem } from '../ports/portConfig.ts';
import { createEdgeConfig } from '../edges/edgeConfig.ts';
import { autoLayout, type LayoutResult } from '../layout/autoLayout.ts';
import { resolvePortSides } from './portSideResolver.ts';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
SPLICE_SIZE,
DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT,
CARD_WIDTH,
CARD_HEIGHT,
} from '../../../constants/sizes.ts';
function getDeviceShape(category: DeviceCategory, deviceName: string, marking: string): string {
if (
category === DeviceCategory.CrossOptical ||
category === DeviceCategory.CrossCopper
) {
return 'cross-device-node';
}
if (
deviceName.toLowerCase().includes('муфта') ||
marking.toLowerCase().includes('мток')
) {
return 'splice-node';
}
return 'device-node';
}
function getDeviceSize(
category: DeviceCategory,
deviceName: string,
marking: string,
portCount: number,
): { width: number; height: number } {
if (
category === DeviceCategory.CrossOptical ||
category === DeviceCategory.CrossCopper
) {
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
}
if (
deviceName.toLowerCase().includes('муфта') ||
marking.toLowerCase().includes('мток')
) {
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
}
const portHeight = Math.max(portCount * 22, 60);
return {
width: DEVICE_MIN_WIDTH,
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
};
}
export function buildGraphData(
data: SchemaData,
routerType: 'manhattan' | 'normal' = 'manhattan',
): GraphBuildResult {
const layout: LayoutResult = autoLayout(data);
const nodes: GraphNodeConfig[] = [];
const edges: GraphEdgeConfig[] = [];
// Resolve port sides based on device positions
const devicePositions = new Map<string, { x: number; y: number }>();
for (const [id, pos] of layout.nodePositions.entries()) {
devicePositions.set(id, { x: pos.x, y: pos.y });
}
const portSideMap = resolvePortSides(data, devicePositions);
// 1. Create site nodes
for (const site of data.sites) {
const sitePos = layout.sitePositions.get(site.id);
if (!sitePos) continue;
nodes.push({
id: site.id,
shape: 'site-node',
x: sitePos.x,
y: sitePos.y,
width: sitePos.width,
height: sitePos.height,
data: {
name: site.name,
address: site.address,
erpCode: site.erpCode,
code1C: site.code1C,
status: site.status,
entityType: 'site',
entityId: site.id,
},
zIndex: 1,
});
}
// 2. Create device nodes with ports
for (const device of data.devices) {
const pos = layout.nodePositions.get(device.id);
if (!pos) continue;
const devicePorts = data.ports.filter(
(p) => p.deviceId === device.id && !p.cardId,
);
const shape = getDeviceShape(device.category, device.name, device.marking);
const size = getDeviceSize(device.category, device.name, device.marking, devicePorts.length);
const portItems = devicePorts.map((port) => {
const resolvedSide = portSideMap.get(port.id) ?? port.side;
const label = port.slotName ? `${port.slotName}:${port.name}` : port.name;
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
});
const node: GraphNodeConfig = {
id: device.id,
shape,
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
data: {
name: device.name,
networkName: device.networkName,
ipAddress: device.ipAddress,
marking: device.marking,
id1: device.id1,
id2: device.id2,
group: device.group,
category: device.category,
status: device.status,
entityType: 'device',
entityId: device.id,
},
ports: {
groups: {
left: {
position: 'left',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: { name: 'left', args: { x: -8 } },
},
},
right: {
position: 'right',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: { name: 'right', args: { x: 8 } },
},
},
},
items: portItems,
},
parent: device.siteId,
zIndex: 2,
};
nodes.push(node);
}
// 3. Create card nodes (embedded inside devices)
for (const card of data.cards) {
if (!card.visible) continue;
const parentDevice = data.devices.find((d) => d.id === card.deviceId);
if (!parentDevice) continue;
const parentPos = layout.nodePositions.get(card.deviceId);
if (!parentPos) continue;
const cardPorts = data.ports.filter((p) => p.cardId === card.id);
const cardIndex = data.cards.filter(
(c) => c.deviceId === card.deviceId && c.visible,
).indexOf(card);
const cardX = parentPos.x + 10;
const cardY = parentPos.y + 40 + cardIndex * (CARD_HEIGHT + 6);
const portItems = cardPorts.map((port) => {
const resolvedSide = portSideMap.get(port.id) ?? port.side;
const label = `${port.slotName}:${port.name}`;
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
});
nodes.push({
id: card.id,
shape: 'card-node',
x: cardX,
y: cardY,
width: CARD_WIDTH,
height: CARD_HEIGHT,
data: {
slotName: card.slotName,
networkName: card.networkName,
status: card.status,
entityType: 'card',
entityId: card.id,
},
ports: {
groups: {
left: {
position: 'left',
attrs: {
circle: {
r: 5,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: { name: 'left', args: { x: -6 } },
},
},
right: {
position: 'right',
attrs: {
circle: {
r: 5,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: { name: 'right', args: { x: 6 } },
},
},
},
items: portItems,
},
parent: card.deviceId,
zIndex: 3,
});
}
// 4. Create edges from lines
for (const line of data.lines) {
const portA = data.ports.find((p) => p.id === line.portAId);
const portZ = data.ports.find((p) => p.id === line.portZId);
if (!portA || !portZ) continue;
// Determine source and target cells
const sourceCellId = portA.cardId ?? portA.deviceId;
const targetCellId = portZ.cardId ?? portZ.deviceId;
edges.push(
createEdgeConfig(
line.id,
sourceCellId,
line.portAId,
targetCellId,
line.portZId,
line.status,
line.medium,
line.lineStyle,
line.name,
routerType,
),
);
}
return { nodes, edges };
}

View File

@ -0,0 +1,64 @@
import type { SchemaData, Port } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
interface DevicePosition {
x: number;
y: number;
}
export function resolvePortSides(
data: SchemaData,
devicePositions: Map<string, DevicePosition>,
): Map<string, 'left' | 'right'> {
const portSideMap = new Map<string, 'left' | 'right'>();
const device = (id: string) => data.devices.find((d) => d.id === id);
for (const port of data.ports) {
const dev = device(port.deviceId);
if (!dev) {
portSideMap.set(port.id, port.side);
continue;
}
// Cross devices: L ports → left, S ports → right (hardcoded)
if (
dev.category === DeviceCategory.CrossOptical ||
dev.category === DeviceCategory.CrossCopper
) {
const side = port.slotName === 'L' || port.name.startsWith('L') ? 'left' : 'right';
portSideMap.set(port.id, side);
continue;
}
// For other devices, determine side based on connected device position
const connectedLine = data.lines.find(
(l) => l.portAId === port.id || l.portZId === port.id,
);
if (!connectedLine) {
portSideMap.set(port.id, port.side);
continue;
}
const otherPortId =
connectedLine.portAId === port.id
? connectedLine.portZId
: connectedLine.portAId;
const otherPort = data.ports.find((p: Port) => p.id === otherPortId);
if (!otherPort) {
portSideMap.set(port.id, port.side);
continue;
}
const thisPos = devicePositions.get(port.deviceId);
const otherPos = devicePositions.get(otherPort.deviceId);
if (thisPos && otherPos) {
portSideMap.set(port.id, otherPos.x < thisPos.x ? 'left' : 'right');
} else {
portSideMap.set(port.id, port.side);
}
}
return portSideMap;
}

View File

@ -0,0 +1,193 @@
import type { Device, Site, SchemaData } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
import { LAYER_MAPPING, MAX_CROSS_PER_LAYER } from '../../../constants/layerMapping.ts';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
SPLICE_SIZE,
DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT,
SITE_HEADER_HEIGHT,
SITE_PADDING,
SITE_MIN_WIDTH,
LAYER_GAP,
DEVICE_GAP,
LAYER_PADDING_X,
} from '../../../constants/sizes.ts';
export interface LayoutResult {
nodePositions: Map<string, { x: number; y: number; width: number; height: number }>;
sitePositions: Map<string, { x: number; y: number; width: number; height: number }>;
}
function getDeviceSize(device: Device, portCount: number): { width: number; height: number } {
if (
device.category === DeviceCategory.CrossOptical ||
device.category === DeviceCategory.CrossCopper
) {
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
}
// Splice device detection by name/marking
if (device.name.toLowerCase().includes('муфта') || device.marking.toLowerCase().includes('мток')) {
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
}
// Dynamic height based on port count
const portHeight = Math.max(portCount * 22, 60);
return {
width: DEVICE_MIN_WIDTH,
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
};
}
function getLayerForDevice(device: Device): number {
for (const [layer, categories] of Object.entries(LAYER_MAPPING)) {
if (categories.includes(device.category)) {
return parseInt(layer, 10);
}
}
return 7; // default to "unknown" layer
}
function getSiteDevices(siteId: string, data: SchemaData): Device[] {
return data.devices.filter((d) => d.siteId === siteId);
}
function layoutDevicesInSite(
devices: Device[],
data: SchemaData,
startX: number,
startY: number,
): { positions: Map<string, { x: number; y: number; width: number; height: number }>; totalWidth: number; totalHeight: number } {
const positions = new Map<string, { x: number; y: number; width: number; height: number }>();
// Assign devices to layers
const layers = new Map<number, { device: Device; size: { width: number; height: number } }[]>();
for (const device of devices) {
const layer = getLayerForDevice(device);
const portCount = data.ports.filter((p) => p.deviceId === device.id && !p.cardId).length;
const size = getDeviceSize(device, portCount);
const existing = layers.get(layer);
if (existing) {
existing.push({ device, size });
} else {
layers.set(layer, [{ device, size }]);
}
}
// Handle overflow: layer 1 with >MAX_CROSS crosses → overflow to layer 8
const layer1 = layers.get(1);
if (layer1 && layer1.length > MAX_CROSS_PER_LAYER) {
const overflow = layer1.splice(MAX_CROSS_PER_LAYER);
const layer8 = layers.get(8) ?? [];
layer8.push(...overflow);
layers.set(8, layer8);
}
// Calculate layer Y positions (skip empty layers)
const sortedLayers = Array.from(layers.entries()).sort((a, b) => a[0] - b[0]);
let currentY = startY;
let maxWidth = 0;
for (const [, layerDevices] of sortedLayers) {
let currentX = startX + LAYER_PADDING_X;
let layerMaxHeight = 0;
for (const { device, size } of layerDevices) {
positions.set(device.id, {
x: currentX,
y: currentY,
width: size.width,
height: size.height,
});
currentX += size.width + DEVICE_GAP;
layerMaxHeight = Math.max(layerMaxHeight, size.height);
}
maxWidth = Math.max(maxWidth, currentX - startX);
currentY += layerMaxHeight + LAYER_GAP;
}
const totalHeight = currentY - startY;
const totalWidth = Math.max(maxWidth + LAYER_PADDING_X, SITE_MIN_WIDTH);
return { positions, totalWidth, totalHeight };
}
export function autoLayout(data: SchemaData): LayoutResult {
const nodePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
const sitePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
// Separate root sites and child sites
const rootSites = data.sites.filter((s: Site) => !s.parentSiteId);
const childSites = data.sites.filter((s: Site) => s.parentSiteId);
let siteX = 50;
for (const site of rootSites) {
const siteDevices = getSiteDevices(site.id, data);
const contentStartY = SITE_HEADER_HEIGHT + SITE_PADDING;
const { positions, totalWidth, totalHeight } = layoutDevicesInSite(
siteDevices,
data,
siteX + SITE_PADDING,
contentStartY + 50, // offset for site positioning
);
// Add device positions
for (const [deviceId, pos] of positions.entries()) {
nodePositions.set(deviceId, pos);
}
// Handle child sites at bottom of parent
const siteChildren = childSites.filter((cs) => cs.parentSiteId === site.id);
let childSiteExtraHeight = 0;
let childX = siteX + SITE_PADDING;
for (const childSite of siteChildren) {
const childDevices = getSiteDevices(childSite.id, data);
const childContentStartY = contentStartY + totalHeight + 50;
const childLayout = layoutDevicesInSite(
childDevices,
data,
childX + SITE_PADDING,
childContentStartY + SITE_HEADER_HEIGHT + SITE_PADDING,
);
for (const [deviceId, pos] of childLayout.positions.entries()) {
nodePositions.set(deviceId, pos);
}
const childSiteWidth = Math.max(childLayout.totalWidth + SITE_PADDING * 2, 250);
const childSiteHeight = childLayout.totalHeight + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
sitePositions.set(childSite.id, {
x: childX,
y: childContentStartY,
width: childSiteWidth,
height: childSiteHeight,
});
childX += childSiteWidth + DEVICE_GAP;
childSiteExtraHeight = Math.max(childSiteExtraHeight, childSiteHeight + SITE_PADDING);
}
const siteWidth = Math.max(totalWidth + SITE_PADDING * 2, childX - siteX);
const siteHeight = totalHeight + contentStartY + childSiteExtraHeight + SITE_PADDING;
sitePositions.set(site.id, {
x: siteX,
y: 50,
width: siteWidth,
height: siteHeight,
});
siteX += siteWidth + 80;
}
return { nodePositions, sitePositions };
}

View File

@ -0,0 +1,44 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface CardNodeData {
slotName: string;
networkName: string;
status: EntityStatus;
}
export function CardNode({ node }: { node: Node }) {
const data = node.getData() as CardNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
borderTop: `1.5px solid ${colors.border}`,
borderBottom: `1.5px solid ${colors.border}`,
borderLeft: `5px solid ${colors.border}`,
borderRight: `5px solid ${colors.border}`,
borderRadius: 0,
background: colors.fill,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
fontSize: 9,
fontWeight: 600,
color: colors.text,
textAlign: 'center',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '0 4px',
}}
>
{data.slotName}:{data.networkName}
</div>
);
}

View File

@ -0,0 +1,83 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { CROSS_BORDER_RADIUS } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface CrossDeviceData {
name: string;
networkName: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function CrossDeviceNode({ node }: { node: Node }) {
const data = node.getData() as CrossDeviceData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
const r = CROSS_BORDER_RADIUS;
const w = size.width;
const h = size.height;
// Asymmetric rounding: top-left and bottom-right rounded
const path = `
M ${r} 0
L ${w} 0
L ${w} ${h - r}
Q ${w} ${h} ${w - r} ${h}
L 0 ${h}
L 0 ${r}
Q 0 0 ${r} 0
Z
`;
return (
<div style={{ width: w, height: h, position: 'relative' }}>
<svg
width={w}
height={h}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<path
d={path}
fill={colors.fill}
stroke={colors.border}
strokeWidth={1}
/>
</svg>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: w,
height: h,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 6px',
boxSizing: 'border-box',
textAlign: 'center',
fontSize: 10,
lineHeight: '14px',
pointerEvents: 'none',
}}
>
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 4, wordBreak: 'break-word' }}>
{data.name}
</div>
{data.networkName && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
)}
{data.marking && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.marking}</div>
)}
{data.id1 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { DEVICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
import { DeviceGroup, type EntityStatus } from '../../../types/index.ts';
interface DeviceNodeData {
name: string;
networkName: string;
ipAddress: string;
marking: string;
id1: string;
id2: string;
group: DeviceGroup;
status: EntityStatus;
}
export function DeviceNode({ node }: { node: Node }) {
const data = node.getData() as DeviceNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
const isActive = data.group === DeviceGroup.Active;
return (
<div
style={{
width: size.width,
height: size.height,
border: `1px solid ${colors.border}`,
borderRadius: DEVICE_BORDER_RADIUS,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
textAlign: 'center',
padding: '6px 8px',
fontSize: 10,
lineHeight: '14px',
overflow: 'hidden',
}}
>
<div
style={{
fontWeight: 700,
fontSize: 11,
marginBottom: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
}}
>
{data.name}
</div>
{isActive ? (
<>
{data.networkName && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
)}
{data.ipAddress && (
<div style={{ color: '#1890ff', fontSize: 9 }}>{data.ipAddress}</div>
)}
</>
) : (
<>
{data.marking && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
)}
{data.id1 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
)}
{data.id2 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id2}</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { SITE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface SiteNodeData {
name: string;
address: string;
erpCode: string;
code1C: string;
status: EntityStatus;
}
export function SiteNode({ node }: { node: Node }) {
const data = node.getData() as SiteNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
border: `3.87px solid ${colors.border}`,
borderRadius: 0,
background: 'transparent',
position: 'relative',
boxSizing: 'border-box',
overflow: 'visible',
pointerEvents: 'none',
}}
>
<div
style={{
height: SITE_HEADER_HEIGHT,
background: '#1a1a2e',
color: '#ffffff',
padding: '6px 10px',
fontSize: 11,
lineHeight: '16px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 1,
pointerEvents: 'auto',
}}
>
<div style={{ fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{data.name}
</div>
<div style={{ opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{data.address}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
ERP: {data.erpCode} | 1С: {data.code1C}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { SPLICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface SpliceNodeData {
name: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function SpliceNode({ node }: { node: Node }) {
const data = node.getData() as SpliceNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
border: `1px solid ${colors.border}`,
borderRadius: SPLICE_BORDER_RADIUS,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
textAlign: 'center',
padding: 4,
fontSize: 10,
lineHeight: '14px',
}}
>
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 2, wordBreak: 'break-word' }}>
{data.name}
</div>
{data.marking && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
)}
</div>
);
}

View File

@ -0,0 +1,63 @@
import { PORT_RADIUS } from '../../../constants/sizes.ts';
export const portGroups = {
left: {
position: 'left',
attrs: {
circle: {
r: PORT_RADIUS,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: {
name: 'left',
args: { x: -8, y: 0 },
},
},
},
right: {
position: 'right',
attrs: {
circle: {
r: PORT_RADIUS,
magnet: true,
stroke: '#8c8c8c',
strokeWidth: 1,
fill: '#fff',
},
},
label: {
position: {
name: 'right',
args: { x: 8, y: 0 },
},
},
},
};
export function createPortItem(
portId: string,
side: 'left' | 'right',
label: string,
labelColor?: string,
) {
return {
id: portId,
group: side,
attrs: {
text: {
text: label,
fontSize: 8,
fill: labelColor || '#595959',
},
circle: {
fill: labelColor || '#fff',
stroke: labelColor || '#8c8c8c',
},
},
};
}

View File

@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useSchemaStore } from '../store/schemaStore.ts';
import type { ContextMenuState } from '../store/schemaStore.ts';
export function useContextMenu() {
const contextMenu = useSchemaStore((state) => state.contextMenu);
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
const showMenu = useCallback(
(menu: Omit<ContextMenuState, 'visible'>) => {
setContextMenu({ ...menu, visible: true });
},
[setContextMenu],
);
const hideMenu = useCallback(() => {
setContextMenu(null);
}, [setContextMenu]);
return { contextMenu, showMenu, hideMenu };
}

View File

@ -0,0 +1,5 @@
import { useSchemaStore } from '../store/schemaStore.ts';
export function useGraph() {
return useSchemaStore((state) => state.graph);
}

12
frontend/src/index.css Normal file
View File

@ -0,0 +1,12 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

5
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(<App />);

View File

@ -0,0 +1,315 @@
import type { SchemaData } from '../types/index.ts';
import {
EntityStatus,
DeviceGroup,
DeviceCategory,
Medium,
LineStyle,
LineType,
} from '../types/index.ts';
export const mockData: SchemaData = {
sites: [
{
id: 'site-1',
name: 'Узел связи Центральный',
address: 'ул. Ленина, 1',
erpCode: 'ERP-001',
code1C: '1C-001',
status: EntityStatus.Active,
parentSiteId: null,
},
{
id: 'site-2',
name: 'Узел связи Северный',
address: 'ул. Мира, 15',
erpCode: 'ERP-002',
code1C: '1C-002',
status: EntityStatus.Active,
parentSiteId: null,
},
{
id: 'site-1-1',
name: 'Подузел Восточный',
address: 'ул. Ленина, 1, корп. 2',
erpCode: 'ERP-003',
code1C: '1C-003',
status: EntityStatus.Planned,
parentSiteId: 'site-1',
},
],
devices: [
// Site 1 devices
{
id: 'dev-cross-1',
name: 'Кросс оптический ОРШ-1',
networkName: 'ORS-1',
ipAddress: '',
marking: 'ОРШ-32',
id1: 'INV-001',
id2: 'SN-001',
group: DeviceGroup.Passive,
category: DeviceCategory.CrossOptical,
status: EntityStatus.Active,
siteId: 'site-1',
},
{
id: 'dev-cross-2',
name: 'Кросс оптический ОРШ-2',
networkName: 'ORS-2',
ipAddress: '',
marking: 'ОРШ-16',
id1: 'INV-002',
id2: 'SN-002',
group: DeviceGroup.Passive,
category: DeviceCategory.CrossOptical,
status: EntityStatus.Reserved,
siteId: 'site-1',
},
{
id: 'dev-dwdm-1',
name: 'DWDM Huawei OSN 8800',
networkName: 'DWDM-HW-01',
ipAddress: '10.0.1.10',
marking: '',
id1: 'INV-003',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.DWDM,
status: EntityStatus.Active,
siteId: 'site-1',
},
{
id: 'dev-router-1',
name: 'Маршрутизатор Cisco ASR 9000',
networkName: 'RTR-CISCO-01',
ipAddress: '10.0.1.1',
marking: '',
id1: 'INV-004',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.IP,
status: EntityStatus.Active,
siteId: 'site-1',
},
{
id: 'dev-switch-1',
name: 'Коммутатор Huawei S6730',
networkName: 'SW-HW-01',
ipAddress: '10.0.1.2',
marking: '',
id1: 'INV-005',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.LanWlan,
status: EntityStatus.Active,
siteId: 'site-1',
},
{
id: 'dev-server-1',
name: 'Сервер мониторинга',
networkName: 'SRV-MON-01',
ipAddress: '10.0.1.100',
marking: '',
id1: 'INV-006',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.Server,
status: EntityStatus.Active,
siteId: 'site-1',
},
// Site 1-1 (child site) device
{
id: 'dev-splice-1',
name: 'Муфта МТОК-96',
networkName: '',
ipAddress: '',
marking: 'МТОК-96',
id1: 'INV-007',
id2: 'SN-007',
group: DeviceGroup.Passive,
category: DeviceCategory.Unknown,
status: EntityStatus.Active,
siteId: 'site-1-1',
},
// Site 2 devices
{
id: 'dev-cross-3',
name: 'Кросс оптический ОРШ-3',
networkName: 'ORS-3',
ipAddress: '',
marking: 'ОРШ-48',
id1: 'INV-008',
id2: 'SN-008',
group: DeviceGroup.Passive,
category: DeviceCategory.CrossOptical,
status: EntityStatus.Active,
siteId: 'site-2',
},
{
id: 'dev-olt-1',
name: 'OLT Huawei MA5800',
networkName: 'OLT-HW-01',
ipAddress: '10.0.2.10',
marking: '',
id1: 'INV-009',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.IP,
status: EntityStatus.UnderConstruction,
siteId: 'site-2',
},
{
id: 'dev-xdsl-1',
name: 'DSLAM ZTE C300',
networkName: 'DSLAM-ZTE-01',
ipAddress: '10.0.2.20',
marking: '',
id1: 'INV-010',
id2: '',
group: DeviceGroup.Active,
category: DeviceCategory.xDSL,
status: EntityStatus.Faulty,
siteId: 'site-2',
},
],
cards: [
{
id: 'card-1',
slotName: '1',
networkName: 'TN12ST2',
status: EntityStatus.Active,
visible: true,
deviceId: 'dev-dwdm-1',
},
{
id: 'card-2',
slotName: '2',
networkName: 'TN11LSX',
status: EntityStatus.Active,
visible: true,
deviceId: 'dev-dwdm-1',
},
{
id: 'card-3',
slotName: '1',
networkName: 'RSP-480',
status: EntityStatus.Active,
visible: true,
deviceId: 'dev-router-1',
},
],
ports: [
// Cross 1 ports (L = left, S = right)
{ id: 'p-c1-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-l3', name: 'L3', slotName: 'L', side: 'left', sortOrder: 3, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-l4', name: 'L4', slotName: 'L', side: 'left', sortOrder: 4, labelColor: '#1890ff', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-s3', name: 'S3', slotName: 'S', side: 'right', sortOrder: 3, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
{ id: 'p-c1-s4', name: 'S4', slotName: 'S', side: 'right', sortOrder: 4, labelColor: '#52c41a', deviceId: 'dev-cross-1', cardId: null },
// Cross 2 ports
{ id: 'p-c2-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-2', cardId: null },
{ id: 'p-c2-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-2', cardId: null },
{ id: 'p-c2-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-2', cardId: null },
{ id: 'p-c2-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-2', cardId: null },
// DWDM card-1 ports
{ id: 'p-dw-c1-1', name: 'IN', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-1' },
{ id: 'p-dw-c1-2', name: 'OUT', slotName: '1', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-1' },
// DWDM card-2 ports
{ id: 'p-dw-c2-1', name: 'IN', slotName: '2', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-2' },
{ id: 'p-dw-c2-2', name: 'OUT', slotName: '2', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: 'card-2' },
// DWDM device-level ports
{ id: 'p-dw-1', name: 'Ge0/0/1', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-dwdm-1', cardId: null },
{ id: 'p-dw-2', name: 'Ge0/0/2', slotName: '0', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-dwdm-1', cardId: null },
// Router card-3 ports
{ id: 'p-rtr-c3-1', name: 'Te0/0/0/0', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-router-1', cardId: 'card-3' },
{ id: 'p-rtr-c3-2', name: 'Te0/0/0/1', slotName: '1', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-router-1', cardId: 'card-3' },
// Router device-level ports
{ id: 'p-rtr-1', name: 'Ge0/0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-router-1', cardId: null },
{ id: 'p-rtr-2', name: 'Ge0/0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-router-1', cardId: null },
{ id: 'p-rtr-3', name: 'Ge0/0/2', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-router-1', cardId: null },
{ id: 'p-rtr-4', name: 'Ge0/0/3', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-router-1', cardId: null },
// Switch ports
{ id: 'p-sw-1', name: 'Ge1/0/1', slotName: '1', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
{ id: 'p-sw-2', name: 'Ge1/0/2', slotName: '1', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
{ id: 'p-sw-3', name: 'Ge1/0/3', slotName: '1', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
{ id: 'p-sw-4', name: 'Ge1/0/4', slotName: '1', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-switch-1', cardId: null },
// Server ports
{ id: 'p-srv-1', name: 'eth0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-server-1', cardId: null },
{ id: 'p-srv-2', name: 'eth1', slotName: '0', side: 'right', sortOrder: 2, labelColor: '', deviceId: 'dev-server-1', cardId: null },
// Splice ports
{ id: 'p-sp-1', name: '1', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
{ id: 'p-sp-2', name: '2', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
{ id: 'p-sp-3', name: '3', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
{ id: 'p-sp-4', name: '4', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-splice-1', cardId: null },
// Cross 3 ports (site 2)
{ id: 'p-c3-l1', name: 'L1', slotName: 'L', side: 'left', sortOrder: 1, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
{ id: 'p-c3-l2', name: 'L2', slotName: 'L', side: 'left', sortOrder: 2, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
{ id: 'p-c3-l3', name: 'L3', slotName: 'L', side: 'left', sortOrder: 3, labelColor: '#1890ff', deviceId: 'dev-cross-3', cardId: null },
{ id: 'p-c3-s1', name: 'S1', slotName: 'S', side: 'right', sortOrder: 1, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
{ id: 'p-c3-s2', name: 'S2', slotName: 'S', side: 'right', sortOrder: 2, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
{ id: 'p-c3-s3', name: 'S3', slotName: 'S', side: 'right', sortOrder: 3, labelColor: '#52c41a', deviceId: 'dev-cross-3', cardId: null },
// OLT ports
{ id: 'p-olt-1', name: 'GPON0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
{ id: 'p-olt-2', name: 'GPON0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
{ id: 'p-olt-3', name: 'Uplink1', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
{ id: 'p-olt-4', name: 'Uplink2', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-olt-1', cardId: null },
// xDSL ports
{ id: 'p-xdsl-1', name: 'ADSL0/0', slotName: '0', side: 'left', sortOrder: 1, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
{ id: 'p-xdsl-2', name: 'ADSL0/1', slotName: '0', side: 'left', sortOrder: 2, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
{ id: 'p-xdsl-3', name: 'Uplink1', slotName: '0', side: 'right', sortOrder: 3, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
{ id: 'p-xdsl-4', name: 'Uplink2', slotName: '0', side: 'right', sortOrder: 4, labelColor: '', deviceId: 'dev-xdsl-1', cardId: null },
],
lines: [
// Cross1 L1 → DWDM card-1 IN (optical, active)
{ id: 'line-1', name: 'ВОК Центр-DWDM-1', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s1', portZId: 'p-dw-c1-1' },
// Cross1 L2 → DWDM card-2 IN (optical, active)
{ id: 'line-2', name: 'ВОК Центр-DWDM-2', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s2', portZId: 'p-dw-c2-1' },
// DWDM card-1 OUT → Router card-3 IN (optical, active)
{ id: 'line-3', name: 'Транзит DWDM-RTR', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-dw-c1-2', portZId: 'p-rtr-c3-1' },
// Router → Switch (copper, active)
{ id: 'line-4', name: 'LAN RTR-SW', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-rtr-3', portZId: 'p-sw-1' },
// Switch → Server (copper, active)
{ id: 'line-5', name: 'LAN SW-SRV', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-sw-3', portZId: 'p-srv-1' },
// Cross1 L3 → Splice (optical, planned)
{ id: 'line-6', name: 'ВОК Центр-Муфта', templateName: '', status: EntityStatus.Planned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dashed, portAId: 'p-c1-s3', portZId: 'p-sp-1' },
// Splice → Cross3 (optical, planned)
{ id: 'line-7', name: 'ВОК Муфта-Северный', templateName: '', status: EntityStatus.Planned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dashed, portAId: 'p-sp-3', portZId: 'p-c3-l1' },
// Cross3 → OLT (optical, under construction)
{ id: 'line-8', name: 'ВОК Кросс3-OLT', templateName: '', status: EntityStatus.UnderConstruction, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c3-s1', portZId: 'p-olt-1' },
// OLT → xDSL (copper, faulty)
{ id: 'line-9', name: 'Транзит OLT-DSLAM', templateName: '', status: EntityStatus.Faulty, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-olt-3', portZId: 'p-xdsl-3' },
// Cross2 → Router (optical, reserved)
{ id: 'line-10', name: 'Резерв Кросс2-RTR', templateName: '', status: EntityStatus.Reserved, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Dotted, portAId: 'p-c2-s1', portZId: 'p-rtr-1' },
// Router → Router (loopback copper)
{ id: 'line-11', name: 'Перемычка RTR', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Solid, portAId: 'p-rtr-2', portZId: 'p-rtr-4' },
// Cross1 → Cross2 (optical, active)
{ id: 'line-12', name: 'Кросс-кросс', templateName: '', status: EntityStatus.Active, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-s4', portZId: 'p-c2-l1' },
// Cross1 L4 → Cross3 L2 (optical, decommissioned)
{ id: 'line-13', name: 'ВОК Центр-Север (выведен)', templateName: '', status: EntityStatus.Decommissioned, type: LineType.Simple, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c1-l4', portZId: 'p-c3-l2' },
// Complex line: Cross3 → xDSL (optical, with fibers)
{ id: 'line-14', name: 'Комплекс Кросс3-DSLAM', templateName: 'TEMPLATE-01', status: EntityStatus.Active, type: LineType.Complex, medium: Medium.Optical, lineStyle: LineStyle.Solid, portAId: 'p-c3-s2', portZId: 'p-xdsl-1' },
// Switch → OLT uplink (copper, unknown)
{ id: 'line-15', name: 'Транзит SW-OLT', templateName: '', status: EntityStatus.Unknown, type: LineType.Simple, medium: Medium.Copper, lineStyle: LineStyle.Dotted, portAId: 'p-sw-4', portZId: 'p-olt-4' },
],
fibers: [
{ id: 'fiber-1', name: 'Волокно 1', status: EntityStatus.Active, lineId: 'line-14', portAId: 'p-c3-s2', portZId: 'p-xdsl-1' },
{ id: 'fiber-2', name: 'Волокно 2', status: EntityStatus.Planned, lineId: 'line-14', portAId: 'p-c3-s3', portZId: 'p-xdsl-2' },
],
};

View File

@ -0,0 +1,108 @@
import { create } from 'zustand';
import type { Graph } from '@antv/x6';
export interface DisplaySettings {
showGrid: boolean;
showMinimap: boolean;
lineType: 'manhattan' | 'normal';
snapToGrid: boolean;
showLabels: boolean;
}
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
type: 'site' | 'active-device' | 'passive-device' | 'line' | 'line-group' | 'blank';
data: Record<string, unknown>;
}
interface SchemaStore {
graph: Graph | null;
mode: 'view' | 'edit';
selectedElements: string[];
displaySettings: DisplaySettings;
contextMenu: ContextMenuState | null;
rightPanelData: Record<string, unknown> | null;
connectionsPanelData: Record<string, unknown> | null;
connectionsPanelVisible: boolean;
legendVisible: boolean;
setGraph: (graph: Graph) => void;
setMode: (mode: 'view' | 'edit') => void;
setSelectedElements: (elements: string[]) => void;
toggleGrid: () => void;
toggleMinimap: () => void;
switchLineType: () => void;
toggleSnapToGrid: () => void;
toggleLabels: () => void;
setContextMenu: (menu: ContextMenuState | null) => void;
setRightPanelData: (data: Record<string, unknown> | null) => void;
setConnectionsPanelData: (data: Record<string, unknown> | null) => void;
setConnectionsPanelVisible: (visible: boolean) => void;
setLegendVisible: (visible: boolean) => void;
}
export const useSchemaStore = create<SchemaStore>((set) => ({
graph: null,
mode: 'view',
selectedElements: [],
displaySettings: {
showGrid: true,
showMinimap: true,
lineType: 'manhattan',
snapToGrid: true,
showLabels: true,
},
contextMenu: null,
rightPanelData: null,
connectionsPanelData: null,
connectionsPanelVisible: false,
legendVisible: false,
setGraph: (graph) => set({ graph }),
setMode: (mode) => set({ mode }),
setSelectedElements: (elements) => set({ selectedElements: elements }),
toggleGrid: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showGrid: !state.displaySettings.showGrid,
},
})),
toggleMinimap: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showMinimap: !state.displaySettings.showMinimap,
},
})),
switchLineType: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
lineType:
state.displaySettings.lineType === 'manhattan' ? 'normal' : 'manhattan',
},
})),
toggleSnapToGrid: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
snapToGrid: !state.displaySettings.snapToGrid,
},
})),
toggleLabels: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showLabels: !state.displaySettings.showLabels,
},
})),
setContextMenu: (menu) => set({ contextMenu: menu }),
setRightPanelData: (data) => set({ rightPanelData: data }),
setConnectionsPanelData: (data) => set({ connectionsPanelData: data }),
setConnectionsPanelVisible: (visible) =>
set({ connectionsPanelVisible: visible }),
setLegendVisible: (visible) => set({ legendVisible: visible }),
}));

View File

@ -0,0 +1,13 @@
import type { Node, Edge } from '@antv/x6';
export type GraphNodeConfig = Node.Metadata & { parent?: string };
export type GraphEdgeConfig = Edge.Metadata;
export interface GraphBuildResult {
nodes: GraphNodeConfig[];
edges: GraphEdgeConfig[];
}
export type X6Node = Node;
export type X6Edge = Edge;

136
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,136 @@
export enum EntityStatus {
Active = 'active',
Planned = 'planned',
UnderConstruction = 'under_construction',
Reserved = 'reserved',
Faulty = 'faulty',
Decommissioned = 'decommissioned',
Unknown = 'unknown',
}
export enum DeviceCategory {
CrossOptical = 'cross_optical',
CrossCopper = 'cross_copper',
RRL = 'rrl',
Wireless = 'wireless',
Satellite = 'satellite',
TSPU = 'tspu',
DWDM = 'dwdm',
MEN = 'men',
SDH = 'sdh',
MultiservicePlatform = 'multiservice_platform',
IP = 'ip',
OpticalModem = 'optical_modem',
OpticalMux = 'optical_mux',
LanWlan = 'lan_wlan',
RanController = 'ran_controller',
MGN = 'mgn',
MGX = 'mgx',
Server = 'server',
SORM = 'sorm',
MOB = 'mob',
FIX = 'fix',
VOIP = 'voip',
xDSL = 'xdsl',
PDH = 'pdh',
RanBaseStation = 'ran_base_station',
Unknown = 'unknown',
VideoSurveillance = 'video_surveillance',
}
export enum DeviceGroup {
Active = 'active',
Passive = 'passive',
}
export enum Medium {
Optical = 'optical',
Copper = 'copper',
Wireless = 'wireless',
Unknown = 'unknown',
}
export enum LineStyle {
Solid = 'solid',
Dashed = 'dashed',
Dotted = 'dotted',
}
export enum LineType {
Simple = 'simple',
Complex = 'complex',
}
export interface Site {
id: string;
name: string;
address: string;
erpCode: string;
code1C: string;
status: EntityStatus;
parentSiteId: string | null;
}
export interface Device {
id: string;
name: string;
networkName: string;
ipAddress: string;
marking: string;
id1: string;
id2: string;
group: DeviceGroup;
category: DeviceCategory;
status: EntityStatus;
siteId: string;
}
export interface Card {
id: string;
slotName: string;
networkName: string;
status: EntityStatus;
visible: boolean;
deviceId: string;
}
export interface Port {
id: string;
name: string;
slotName: string;
side: 'left' | 'right';
sortOrder: number;
labelColor: string;
deviceId: string;
cardId: string | null;
}
export interface Line {
id: string;
name: string;
templateName: string;
status: EntityStatus;
type: LineType;
medium: Medium;
lineStyle: LineStyle;
portAId: string;
portZId: string;
}
export interface Fiber {
id: string;
name: string;
status: EntityStatus;
lineId: string;
portAId: string;
portZId: string;
}
export interface SchemaData {
sites: Site[];
devices: Device[];
cards: Card[];
ports: Port[];
lines: Line[];
fibers: Fiber[];
}

View File

@ -0,0 +1,14 @@
let counter = 0;
export function generateId(prefix = 'id'): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
export function isCrossDevice(category: string): boolean {
return category === 'cross_optical' || category === 'cross_copper';
}
export function isSpliceDevice(category: string): boolean {
return category === 'splice';
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})