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

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

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

24
frontend/.gitignore vendored Normal file
View File

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

73
frontend/README.md Normal file
View File

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

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

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

13
frontend/index.html Normal file
View File

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

4512
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

7
frontend/tsconfig.json Normal file
View File

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

View File

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

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

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