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

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',
},
},
};
}