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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.sw?
|
||||||
531
ARCHITECTURE.md
Normal file
531
ARCHITECTURE.md
Normal 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
97
CLAUDE.md
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4512
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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
26
frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/AppLayout.tsx
Normal file
119
frontend/src/components/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/components/ConnectionsPanel/ConnectionsPanel.tsx
Normal file
170
frontend/src/components/ConnectionsPanel/ConnectionsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/components/Legend/LegendModal.tsx
Normal file
86
frontend/src/components/Legend/LegendModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend/src/components/SidePanel/LeftPanel.tsx
Normal file
149
frontend/src/components/SidePanel/LeftPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/SidePanel/RightPanel.tsx
Normal file
134
frontend/src/components/SidePanel/RightPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
frontend/src/components/Toolbar/Toolbar.tsx
Normal file
176
frontend/src/components/Toolbar/Toolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/constants/layerMapping.ts
Normal file
40
frontend/src/constants/layerMapping.ts
Normal 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;
|
||||||
29
frontend/src/constants/lineStyles.ts
Normal file
29
frontend/src/constants/lineStyles.ts
Normal 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
24
frontend/src/constants/sizes.ts
Normal file
24
frontend/src/constants/sizes.ts
Normal 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;
|
||||||
55
frontend/src/constants/statusColors.ts
Normal file
55
frontend/src/constants/statusColors.ts
Normal 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]: 'Неизвестно',
|
||||||
|
};
|
||||||
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal file
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal file
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal file
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal file
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal 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;
|
||||||
|
}
|
||||||
207
frontend/src/features/schema/graph/initGraph.ts
Normal file
207
frontend/src/features/schema/graph/initGraph.ts
Normal 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;
|
||||||
|
}
|
||||||
64
frontend/src/features/schema/graph/registerNodes.ts
Normal file
64
frontend/src/features/schema/graph/registerNodes.ts
Normal 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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal file
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal 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 };
|
||||||
|
}
|
||||||
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal file
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal 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;
|
||||||
|
}
|
||||||
193
frontend/src/features/schema/layout/autoLayout.ts
Normal file
193
frontend/src/features/schema/layout/autoLayout.ts
Normal 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 };
|
||||||
|
}
|
||||||
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal file
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal file
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal file
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal file
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal file
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/features/schema/ports/portConfig.ts
Normal file
63
frontend/src/features/schema/ports/portConfig.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
21
frontend/src/hooks/useContextMenu.ts
Normal file
21
frontend/src/hooks/useContextMenu.ts
Normal 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 };
|
||||||
|
}
|
||||||
5
frontend/src/hooks/useGraph.ts
Normal file
5
frontend/src/hooks/useGraph.ts
Normal 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
12
frontend/src/index.css
Normal 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
5
frontend/src/main.tsx
Normal 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 />);
|
||||||
315
frontend/src/mock/schemaData.ts
Normal file
315
frontend/src/mock/schemaData.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
108
frontend/src/store/schemaStore.ts
Normal file
108
frontend/src/store/schemaStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
13
frontend/src/types/graph.ts
Normal file
13
frontend/src/types/graph.ts
Normal 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
136
frontend/src/types/index.ts
Normal 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[];
|
||||||
|
}
|
||||||
14
frontend/src/utils/index.ts
Normal file
14
frontend/src/utils/index.ts
Normal 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';
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user