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:
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal file
228
frontend/src/features/schema/SchemaCanvas.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSchemaStore } from '../../store/schemaStore.ts';
|
||||
import { initGraph } from './graph/initGraph.ts';
|
||||
import { registerAllNodes } from './graph/registerNodes.ts';
|
||||
import { buildGraphData } from './helpers/dataMapper.ts';
|
||||
import { mockData } from '../../mock/schemaData.ts';
|
||||
import { DeviceGroup } from '../../types/index.ts';
|
||||
|
||||
let nodesRegistered = false;
|
||||
|
||||
export function SchemaCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const minimapRef = useRef<HTMLDivElement>(null);
|
||||
const setGraph = useSchemaStore((state) => state.setGraph);
|
||||
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
|
||||
const setRightPanelData = useSchemaStore((state) => state.setRightPanelData);
|
||||
const setConnectionsPanelData = useSchemaStore(
|
||||
(state) => state.setConnectionsPanelData,
|
||||
);
|
||||
const setConnectionsPanelVisible = useSchemaStore(
|
||||
(state) => state.setConnectionsPanelVisible,
|
||||
);
|
||||
const displaySettings = useSchemaStore((state) => state.displaySettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!nodesRegistered) {
|
||||
registerAllNodes();
|
||||
nodesRegistered = true;
|
||||
}
|
||||
|
||||
const graph = initGraph(containerRef.current, minimapRef.current);
|
||||
setGraph(graph);
|
||||
|
||||
const { nodes, edges } = buildGraphData(mockData, displaySettings.lineType);
|
||||
|
||||
// Add nodes first (sites, then devices, then cards)
|
||||
const siteNodes = nodes.filter((n) => n.shape === 'site-node');
|
||||
const deviceNodes = nodes.filter(
|
||||
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
|
||||
);
|
||||
const cardNodes = nodes.filter((n) => n.shape === 'card-node');
|
||||
|
||||
for (const node of siteNodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const node of deviceNodes) {
|
||||
const graphNode = graph.addNode(node);
|
||||
// Set parent (embed in site)
|
||||
if (node.parent) {
|
||||
const parentNode = graph.getCellById(node.parent);
|
||||
if (parentNode) {
|
||||
parentNode.addChild(graphNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of cardNodes) {
|
||||
const graphNode = graph.addNode(node);
|
||||
if (node.parent) {
|
||||
const parentNode = graph.getCellById(node.parent);
|
||||
if (parentNode) {
|
||||
parentNode.addChild(graphNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add edges
|
||||
for (const edge of edges) {
|
||||
graph.addEdge(edge);
|
||||
}
|
||||
|
||||
// Center content
|
||||
graph.centerContent();
|
||||
|
||||
// Event handlers
|
||||
graph.on('node:click', ({ node }) => {
|
||||
const data = node.getData() as Record<string, unknown>;
|
||||
setRightPanelData(data);
|
||||
});
|
||||
|
||||
graph.on('edge:click', ({ edge }) => {
|
||||
const data = edge.getData() as Record<string, unknown>;
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
if (line) {
|
||||
setRightPanelData({
|
||||
entityType: 'line',
|
||||
entityId: line.id,
|
||||
name: line.name,
|
||||
status: line.status,
|
||||
medium: line.medium,
|
||||
lineStyle: line.lineStyle,
|
||||
type: line.type,
|
||||
});
|
||||
} else if (data) {
|
||||
setRightPanelData(data);
|
||||
}
|
||||
});
|
||||
|
||||
graph.on('node:contextmenu', ({ e, node }) => {
|
||||
e.preventDefault();
|
||||
const data = node.getData() as Record<string, unknown>;
|
||||
const entityType = data.entityType as string;
|
||||
|
||||
let menuType: 'site' | 'active-device' | 'passive-device' = 'active-device';
|
||||
if (entityType === 'site') {
|
||||
menuType = 'site';
|
||||
} else if (entityType === 'device') {
|
||||
const group = data.group as string;
|
||||
menuType = group === DeviceGroup.Passive ? 'passive-device' : 'active-device';
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: menuType,
|
||||
data: data,
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('edge:contextmenu', ({ e, edge }) => {
|
||||
e.preventDefault();
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: 'line',
|
||||
data: line
|
||||
? {
|
||||
entityType: 'line',
|
||||
entityId: line.id,
|
||||
name: line.name,
|
||||
status: line.status,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('blank:contextmenu', ({ e }) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type: 'blank',
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
graph.on('blank:click', () => {
|
||||
setContextMenu(null);
|
||||
setRightPanelData(null);
|
||||
});
|
||||
|
||||
// Port side recalculation on node move
|
||||
graph.on('node:moved', () => {
|
||||
// In a full implementation, we'd recalculate port sides here
|
||||
});
|
||||
|
||||
// Show connections panel on edge double click
|
||||
graph.on('edge:dblclick', ({ edge }) => {
|
||||
const line = mockData.lines.find((l) => l.id === edge.id);
|
||||
if (line) {
|
||||
const portA = mockData.ports.find((p) => p.id === line.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === line.portZId);
|
||||
const devA = portA
|
||||
? mockData.devices.find((d) => d.id === portA.deviceId)
|
||||
: null;
|
||||
const devZ = portZ
|
||||
? mockData.devices.find((d) => d.id === portZ.deviceId)
|
||||
: null;
|
||||
|
||||
setConnectionsPanelData({
|
||||
line,
|
||||
portA,
|
||||
portZ,
|
||||
deviceA: devA,
|
||||
deviceZ: devZ,
|
||||
});
|
||||
setConnectionsPanelVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
graph.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync display settings
|
||||
useEffect(() => {
|
||||
const graph = useSchemaStore.getState().graph;
|
||||
if (!graph) return;
|
||||
|
||||
if (displaySettings.showGrid) {
|
||||
graph.showGrid();
|
||||
} else {
|
||||
graph.hideGrid();
|
||||
}
|
||||
}, [displaySettings.showGrid]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div
|
||||
ref={minimapRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
width: 200,
|
||||
height: 150,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
display: displaySettings.showMinimap ? 'block' : 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal file
139
frontend/src/features/schema/context-menu/ContextMenu.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Dropdown, message } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu.ts';
|
||||
import { useSchemaStore } from '../../../store/schemaStore.ts';
|
||||
import { mockData } from '../../../mock/schemaData.ts';
|
||||
|
||||
export function ContextMenu() {
|
||||
const { contextMenu, hideMenu } = useContextMenu();
|
||||
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
|
||||
const setConnectionsPanelVisible = useSchemaStore(
|
||||
(s) => s.setConnectionsPanelVisible,
|
||||
);
|
||||
const setConnectionsPanelData = useSchemaStore(
|
||||
(s) => s.setConnectionsPanelData,
|
||||
);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => hideMenu();
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [hideMenu]);
|
||||
|
||||
if (!contextMenu?.visible) return null;
|
||||
|
||||
const wip = () => message.info('В разработке');
|
||||
|
||||
const siteMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
|
||||
{ key: 'edit', label: 'Редактировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const activeDeviceMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const deviceId = contextMenu.data.entityId as string;
|
||||
const deviceLines = mockData.lines.filter((l) => {
|
||||
const portA = mockData.ports.find((p) => p.id === l.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === l.portZId);
|
||||
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
|
||||
});
|
||||
setConnectionsPanelData({ lines: deviceLines, deviceId });
|
||||
setConnectionsPanelVisible(true);
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'create-line', label: 'Создать линию', onClick: wip },
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ key: 'move', label: 'Переместить на другой сайт', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const passiveDeviceMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const deviceId = contextMenu.data.entityId as string;
|
||||
const deviceLines = mockData.lines.filter((l) => {
|
||||
const portA = mockData.ports.find((p) => p.id === l.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === l.portZId);
|
||||
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
|
||||
});
|
||||
setConnectionsPanelData({ lines: deviceLines, deviceId });
|
||||
setConnectionsPanelVisible(true);
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const lineMenu: MenuProps['items'] = [
|
||||
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
|
||||
{ key: 'connections', label: 'Показать соединения', onClick: () => {
|
||||
const lineId = contextMenu.data.entityId as string;
|
||||
const line = mockData.lines.find((l) => l.id === lineId);
|
||||
if (line) {
|
||||
const portA = mockData.ports.find((p) => p.id === line.portAId);
|
||||
const portZ = mockData.ports.find((p) => p.id === line.portZId);
|
||||
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
|
||||
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
|
||||
setConnectionsPanelData({ line, portA, portZ, deviceA: devA, deviceZ: devZ });
|
||||
setConnectionsPanelVisible(true);
|
||||
}
|
||||
hideMenu();
|
||||
}},
|
||||
{ key: 'break', label: 'Разорвать линию', onClick: wip },
|
||||
{ key: 'copy', label: 'Копировать', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
|
||||
];
|
||||
|
||||
const blankMenu: MenuProps['items'] = [
|
||||
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
|
||||
{ key: 'create-line', label: 'Создать линию', onClick: wip },
|
||||
{ key: 'paste', label: 'Вставить', onClick: wip },
|
||||
{ type: 'divider' },
|
||||
{ key: 'fit', label: 'Уместить на экран', onClick: () => {
|
||||
const graph = useSchemaStore.getState().graph;
|
||||
graph?.zoomToFit({ padding: 40 });
|
||||
hideMenu();
|
||||
}},
|
||||
];
|
||||
|
||||
const menuMap: Record<string, MenuProps['items']> = {
|
||||
'site': siteMenu,
|
||||
'active-device': activeDeviceMenu,
|
||||
'passive-device': passiveDeviceMenu,
|
||||
'line': lineMenu,
|
||||
'line-group': lineMenu,
|
||||
'blank': blankMenu,
|
||||
};
|
||||
|
||||
const items = menuMap[contextMenu.type] ?? blankMenu;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
open={true}
|
||||
onOpenChange={(open) => { if (!open) hideMenu(); }}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<div style={{ width: 1, height: 1 }} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal file
76
frontend/src/features/schema/edges/edgeConfig.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
import { Medium, LineStyle } from '../../../types/index.ts';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { getLineVisualStyle } from '../../../constants/lineStyles.ts';
|
||||
import type { GraphEdgeConfig } from '../../../types/graph.ts';
|
||||
|
||||
export function createEdgeConfig(
|
||||
id: string,
|
||||
sourceCell: string,
|
||||
sourcePort: string,
|
||||
targetCell: string,
|
||||
targetPort: string,
|
||||
status: EntityStatus,
|
||||
medium: Medium,
|
||||
lineStyle: LineStyle,
|
||||
label: string,
|
||||
routerType: 'manhattan' | 'normal' = 'manhattan',
|
||||
): GraphEdgeConfig {
|
||||
const colors = STATUS_COLORS[status];
|
||||
const visualStyle = getLineVisualStyle(lineStyle, medium);
|
||||
|
||||
const router =
|
||||
routerType === 'manhattan'
|
||||
? { name: 'manhattan' as const, args: { padding: 20 } }
|
||||
: { name: 'normal' as const };
|
||||
|
||||
return {
|
||||
id,
|
||||
source: { cell: sourceCell, port: sourcePort },
|
||||
target: { cell: targetCell, port: targetPort },
|
||||
router,
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: colors.border,
|
||||
strokeWidth: visualStyle.strokeWidth,
|
||||
strokeDasharray: visualStyle.strokeDasharray || undefined,
|
||||
targetMarker: null,
|
||||
sourceMarker: null,
|
||||
},
|
||||
wrap: {
|
||||
fill: 'none',
|
||||
stroke: 'transparent',
|
||||
strokeWidth: 20,
|
||||
},
|
||||
},
|
||||
labels: label
|
||||
? [
|
||||
{
|
||||
attrs: {
|
||||
label: {
|
||||
text: label,
|
||||
fontSize: 9,
|
||||
fill: '#595959',
|
||||
textAnchor: 'middle',
|
||||
textVerticalAnchor: 'middle',
|
||||
},
|
||||
rect: {
|
||||
ref: 'label',
|
||||
fill: '#fff',
|
||||
rx: 3,
|
||||
ry: 3,
|
||||
refWidth: '140%',
|
||||
refHeight: '140%',
|
||||
refX: '-20%',
|
||||
refY: '-20%',
|
||||
},
|
||||
},
|
||||
position: { distance: 0.5 },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
data: { status, medium, lineStyle, label },
|
||||
zIndex: 5,
|
||||
};
|
||||
}
|
||||
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal file
76
frontend/src/features/schema/edges/edgeGrouping.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { Line, Port, Device, SchemaData } from '../../../types/index.ts';
|
||||
import { STATUS_COLORS, STATUS_LABELS } from '../../../constants/statusColors.ts';
|
||||
|
||||
export interface LineGroup {
|
||||
key: string;
|
||||
deviceAId: string;
|
||||
deviceZId: string;
|
||||
lines: Line[];
|
||||
representativeLine: Line;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function getDeviceIdForPort(
|
||||
portId: string,
|
||||
data: SchemaData,
|
||||
): string | null {
|
||||
const port = data.ports.find((p: Port) => p.id === portId);
|
||||
return port ? port.deviceId : null;
|
||||
}
|
||||
|
||||
export function groupLinesByDevicePair(data: SchemaData): LineGroup[] {
|
||||
const groupMap = new Map<string, Line[]>();
|
||||
|
||||
for (const line of data.lines) {
|
||||
const devA = getDeviceIdForPort(line.portAId, data);
|
||||
const devZ = getDeviceIdForPort(line.portZId, data);
|
||||
if (!devA || !devZ) continue;
|
||||
|
||||
const key = [devA, devZ].sort().join('::');
|
||||
const existing = groupMap.get(key);
|
||||
if (existing) {
|
||||
existing.push(line);
|
||||
} else {
|
||||
groupMap.set(key, [line]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: LineGroup[] = [];
|
||||
for (const [key, lines] of groupMap.entries()) {
|
||||
const [deviceAId, deviceZId] = key.split('::');
|
||||
groups.push({
|
||||
key,
|
||||
deviceAId,
|
||||
deviceZId,
|
||||
lines,
|
||||
representativeLine: lines[0],
|
||||
count: lines.length,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function getGroupTooltip(
|
||||
group: LineGroup,
|
||||
devices: Device[],
|
||||
): string {
|
||||
const devA = devices.find((d) => d.id === group.deviceAId);
|
||||
const devZ = devices.find((d) => d.id === group.deviceZId);
|
||||
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const line of group.lines) {
|
||||
const current = statusCounts.get(line.status) ?? 0;
|
||||
statusCounts.set(line.status, current + 1);
|
||||
}
|
||||
|
||||
let tooltip = `${devA?.name ?? '?'} — ${devZ?.name ?? '?'}\n`;
|
||||
tooltip += `Линий: ${group.count}\n`;
|
||||
for (const [status, count] of statusCounts.entries()) {
|
||||
const color = STATUS_COLORS[status as keyof typeof STATUS_COLORS];
|
||||
const label = STATUS_LABELS[status as keyof typeof STATUS_LABELS];
|
||||
tooltip += ` ${label}: ${count} (${color.border})\n`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
207
frontend/src/features/schema/graph/initGraph.ts
Normal file
207
frontend/src/features/schema/graph/initGraph.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import { Selection } from '@antv/x6-plugin-selection';
|
||||
import { Snapline } from '@antv/x6-plugin-snapline';
|
||||
import { MiniMap } from '@antv/x6-plugin-minimap';
|
||||
import { Keyboard } from '@antv/x6-plugin-keyboard';
|
||||
import { Clipboard } from '@antv/x6-plugin-clipboard';
|
||||
import { History } from '@antv/x6-plugin-history';
|
||||
|
||||
|
||||
export function initGraph(
|
||||
container: HTMLDivElement,
|
||||
minimapContainer: HTMLDivElement | null,
|
||||
): Graph {
|
||||
const graph = new Graph({
|
||||
container,
|
||||
autoResize: true,
|
||||
background: { color: '#f8f9fa' },
|
||||
grid: {
|
||||
visible: true,
|
||||
size: 10,
|
||||
type: 'dot',
|
||||
args: { color: '#d9d9d9', thickness: 1 },
|
||||
},
|
||||
panning: { enabled: true, eventTypes: ['rightMouseDown'] },
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
modifiers: ['ctrl', 'meta'],
|
||||
minScale: 0.1,
|
||||
maxScale: 3,
|
||||
},
|
||||
connecting: {
|
||||
snap: { radius: 30 },
|
||||
allowBlank: false,
|
||||
allowLoop: true,
|
||||
allowNode: false,
|
||||
allowEdge: false,
|
||||
allowMulti: true,
|
||||
allowPort: true,
|
||||
highlight: true,
|
||||
router: { name: 'manhattan' },
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
connectionPoint: 'anchor',
|
||||
anchor: 'center',
|
||||
sourceAnchor: 'center',
|
||||
targetAnchor: 'center',
|
||||
createEdge() {
|
||||
return this.createEdge({
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: '#52c41a',
|
||||
strokeWidth: 2,
|
||||
targetMarker: null,
|
||||
sourceMarker: null,
|
||||
},
|
||||
},
|
||||
router: { name: 'manhattan' },
|
||||
connector: { name: 'rounded', args: { radius: 8 } },
|
||||
});
|
||||
},
|
||||
validateConnection({ sourcePort, targetPort, sourceMagnet, targetMagnet }) {
|
||||
if (!sourceMagnet || !targetMagnet) return false;
|
||||
if (!sourcePort || !targetPort) return false;
|
||||
return sourcePort !== targetPort;
|
||||
},
|
||||
},
|
||||
embedding: {
|
||||
enabled: true,
|
||||
findParent({ node }) {
|
||||
// Only site nodes can be parents
|
||||
const bbox = node.getBBox();
|
||||
return this.getNodes().filter((n) => {
|
||||
if (n.id === node.id) return false;
|
||||
const data = n.getData<{ entityType?: string }>();
|
||||
if (data?.entityType !== 'site') return false;
|
||||
return n.getBBox().containsRect(bbox);
|
||||
});
|
||||
},
|
||||
},
|
||||
interacting: {
|
||||
nodeMovable: true,
|
||||
edgeMovable: true,
|
||||
edgeLabelMovable: true,
|
||||
vertexMovable: true,
|
||||
vertexAddable: true,
|
||||
vertexDeletable: true,
|
||||
},
|
||||
highlighting: {
|
||||
magnetAvailable: {
|
||||
name: 'stroke',
|
||||
args: { attrs: { fill: '#5F95FF', stroke: '#5F95FF' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Show tools on edge hover: segments, vertices, and arrowheads for reconnecting
|
||||
graph.on('edge:mouseenter', ({ edge }) => {
|
||||
edge.addTools([
|
||||
{
|
||||
name: 'segments',
|
||||
args: {
|
||||
snapRadius: 20,
|
||||
attrs: {
|
||||
fill: '#444',
|
||||
width: 20,
|
||||
height: 8,
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vertices',
|
||||
args: {
|
||||
attrs: {
|
||||
r: 5,
|
||||
fill: '#fff',
|
||||
stroke: '#333',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
snapRadius: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'source-arrowhead',
|
||||
args: {
|
||||
attrs: {
|
||||
d: 'M 0 -5 L 10 0 L 0 5 Z',
|
||||
fill: '#333',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'target-arrowhead',
|
||||
args: {
|
||||
attrs: {
|
||||
d: 'M 0 -5 L 10 0 L 0 5 Z',
|
||||
fill: '#333',
|
||||
stroke: '#fff',
|
||||
strokeWidth: 1,
|
||||
cursor: 'move',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
graph.on('edge:mouseleave', ({ edge }) => {
|
||||
// Don't remove tools if edge is being dragged
|
||||
if (edge.hasTool('source-arrowhead') || edge.hasTool('target-arrowhead')) {
|
||||
// Delay removal to avoid removing during drag
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector('.x6-widget-transform')) {
|
||||
edge.removeTools();
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
edge.removeTools();
|
||||
}
|
||||
});
|
||||
|
||||
graph.use(
|
||||
new Selection({
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
rubberband: true,
|
||||
movable: true,
|
||||
showNodeSelectionBox: true,
|
||||
}),
|
||||
);
|
||||
|
||||
graph.use(new Snapline({ enabled: true }));
|
||||
|
||||
if (minimapContainer) {
|
||||
graph.use(
|
||||
new MiniMap({
|
||||
container: minimapContainer,
|
||||
width: 200,
|
||||
height: 150,
|
||||
padding: 10,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
graph.use(new Keyboard({ enabled: true }));
|
||||
graph.use(new Clipboard({ enabled: true }));
|
||||
graph.use(new History({ enabled: true }));
|
||||
|
||||
// Keyboard shortcuts
|
||||
graph.bindKey('ctrl+z', () => graph.undo());
|
||||
graph.bindKey('ctrl+shift+z', () => graph.redo());
|
||||
graph.bindKey('ctrl+c', () => graph.copy(graph.getSelectedCells()));
|
||||
graph.bindKey('ctrl+v', () => graph.paste());
|
||||
graph.bindKey('delete', () => {
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length) graph.removeCells(cells);
|
||||
});
|
||||
graph.bindKey('ctrl+a', () => {
|
||||
const cells = graph.getCells();
|
||||
graph.select(cells);
|
||||
});
|
||||
|
||||
return graph;
|
||||
}
|
||||
64
frontend/src/features/schema/graph/registerNodes.ts
Normal file
64
frontend/src/features/schema/graph/registerNodes.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import { SiteNode } from '../nodes/SiteNode.tsx';
|
||||
import { CrossDeviceNode } from '../nodes/CrossDeviceNode.tsx';
|
||||
import { SpliceNode } from '../nodes/SpliceNode.tsx';
|
||||
import { DeviceNode } from '../nodes/DeviceNode.tsx';
|
||||
import { CardNode } from '../nodes/CardNode.tsx';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
SITE_MIN_WIDTH,
|
||||
SITE_MIN_HEIGHT,
|
||||
} from '../../../constants/sizes.ts';
|
||||
import { portGroups } from '../ports/portConfig.ts';
|
||||
|
||||
export function registerAllNodes(): void {
|
||||
register({
|
||||
shape: 'site-node',
|
||||
width: SITE_MIN_WIDTH,
|
||||
height: SITE_MIN_HEIGHT,
|
||||
component: SiteNode,
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'cross-device-node',
|
||||
width: CROSS_WIDTH,
|
||||
height: CROSS_HEIGHT,
|
||||
component: CrossDeviceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'splice-node',
|
||||
width: SPLICE_SIZE,
|
||||
height: SPLICE_SIZE,
|
||||
component: SpliceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'device-node',
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: DEVICE_MIN_HEIGHT,
|
||||
component: DeviceNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
|
||||
register({
|
||||
shape: 'card-node',
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
component: CardNode,
|
||||
ports: { groups: portGroups },
|
||||
effect: ['data'],
|
||||
});
|
||||
}
|
||||
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal file
283
frontend/src/features/schema/helpers/dataMapper.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import type { SchemaData } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
import type { GraphNodeConfig, GraphEdgeConfig, GraphBuildResult } from '../../../types/graph.ts';
|
||||
import { createPortItem } from '../ports/portConfig.ts';
|
||||
import { createEdgeConfig } from '../edges/edgeConfig.ts';
|
||||
import { autoLayout, type LayoutResult } from '../layout/autoLayout.ts';
|
||||
import { resolvePortSides } from './portSideResolver.ts';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
CARD_HEIGHT,
|
||||
} from '../../../constants/sizes.ts';
|
||||
|
||||
function getDeviceShape(category: DeviceCategory, deviceName: string, marking: string): string {
|
||||
if (
|
||||
category === DeviceCategory.CrossOptical ||
|
||||
category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return 'cross-device-node';
|
||||
}
|
||||
if (
|
||||
deviceName.toLowerCase().includes('муфта') ||
|
||||
marking.toLowerCase().includes('мток')
|
||||
) {
|
||||
return 'splice-node';
|
||||
}
|
||||
return 'device-node';
|
||||
}
|
||||
|
||||
function getDeviceSize(
|
||||
category: DeviceCategory,
|
||||
deviceName: string,
|
||||
marking: string,
|
||||
portCount: number,
|
||||
): { width: number; height: number } {
|
||||
if (
|
||||
category === DeviceCategory.CrossOptical ||
|
||||
category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
|
||||
}
|
||||
if (
|
||||
deviceName.toLowerCase().includes('муфта') ||
|
||||
marking.toLowerCase().includes('мток')
|
||||
) {
|
||||
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
|
||||
}
|
||||
const portHeight = Math.max(portCount * 22, 60);
|
||||
return {
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphData(
|
||||
data: SchemaData,
|
||||
routerType: 'manhattan' | 'normal' = 'manhattan',
|
||||
): GraphBuildResult {
|
||||
const layout: LayoutResult = autoLayout(data);
|
||||
const nodes: GraphNodeConfig[] = [];
|
||||
const edges: GraphEdgeConfig[] = [];
|
||||
|
||||
// Resolve port sides based on device positions
|
||||
const devicePositions = new Map<string, { x: number; y: number }>();
|
||||
for (const [id, pos] of layout.nodePositions.entries()) {
|
||||
devicePositions.set(id, { x: pos.x, y: pos.y });
|
||||
}
|
||||
const portSideMap = resolvePortSides(data, devicePositions);
|
||||
|
||||
// 1. Create site nodes
|
||||
for (const site of data.sites) {
|
||||
const sitePos = layout.sitePositions.get(site.id);
|
||||
if (!sitePos) continue;
|
||||
|
||||
nodes.push({
|
||||
id: site.id,
|
||||
shape: 'site-node',
|
||||
x: sitePos.x,
|
||||
y: sitePos.y,
|
||||
width: sitePos.width,
|
||||
height: sitePos.height,
|
||||
data: {
|
||||
name: site.name,
|
||||
address: site.address,
|
||||
erpCode: site.erpCode,
|
||||
code1C: site.code1C,
|
||||
status: site.status,
|
||||
entityType: 'site',
|
||||
entityId: site.id,
|
||||
},
|
||||
zIndex: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create device nodes with ports
|
||||
for (const device of data.devices) {
|
||||
const pos = layout.nodePositions.get(device.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const devicePorts = data.ports.filter(
|
||||
(p) => p.deviceId === device.id && !p.cardId,
|
||||
);
|
||||
const shape = getDeviceShape(device.category, device.name, device.marking);
|
||||
const size = getDeviceSize(device.category, device.name, device.marking, devicePorts.length);
|
||||
|
||||
const portItems = devicePorts.map((port) => {
|
||||
const resolvedSide = portSideMap.get(port.id) ?? port.side;
|
||||
const label = port.slotName ? `${port.slotName}:${port.name}` : port.name;
|
||||
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
|
||||
});
|
||||
|
||||
const node: GraphNodeConfig = {
|
||||
id: device.id,
|
||||
shape,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
data: {
|
||||
name: device.name,
|
||||
networkName: device.networkName,
|
||||
ipAddress: device.ipAddress,
|
||||
marking: device.marking,
|
||||
id1: device.id1,
|
||||
id2: device.id2,
|
||||
group: device.group,
|
||||
category: device.category,
|
||||
status: device.status,
|
||||
entityType: 'device',
|
||||
entityId: device.id,
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 6,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'left', args: { x: -8 } },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 6,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'right', args: { x: 8 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: portItems,
|
||||
},
|
||||
parent: device.siteId,
|
||||
zIndex: 2,
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// 3. Create card nodes (embedded inside devices)
|
||||
for (const card of data.cards) {
|
||||
if (!card.visible) continue;
|
||||
|
||||
const parentDevice = data.devices.find((d) => d.id === card.deviceId);
|
||||
if (!parentDevice) continue;
|
||||
|
||||
const parentPos = layout.nodePositions.get(card.deviceId);
|
||||
if (!parentPos) continue;
|
||||
|
||||
const cardPorts = data.ports.filter((p) => p.cardId === card.id);
|
||||
const cardIndex = data.cards.filter(
|
||||
(c) => c.deviceId === card.deviceId && c.visible,
|
||||
).indexOf(card);
|
||||
|
||||
const cardX = parentPos.x + 10;
|
||||
const cardY = parentPos.y + 40 + cardIndex * (CARD_HEIGHT + 6);
|
||||
|
||||
const portItems = cardPorts.map((port) => {
|
||||
const resolvedSide = portSideMap.get(port.id) ?? port.side;
|
||||
const label = `${port.slotName}:${port.name}`;
|
||||
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined);
|
||||
});
|
||||
|
||||
nodes.push({
|
||||
id: card.id,
|
||||
shape: 'card-node',
|
||||
x: cardX,
|
||||
y: cardY,
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
data: {
|
||||
slotName: card.slotName,
|
||||
networkName: card.networkName,
|
||||
status: card.status,
|
||||
entityType: 'card',
|
||||
entityId: card.id,
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 5,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'left', args: { x: -6 } },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 5,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: { name: 'right', args: { x: 6 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: portItems,
|
||||
},
|
||||
parent: card.deviceId,
|
||||
zIndex: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Create edges from lines
|
||||
for (const line of data.lines) {
|
||||
const portA = data.ports.find((p) => p.id === line.portAId);
|
||||
const portZ = data.ports.find((p) => p.id === line.portZId);
|
||||
if (!portA || !portZ) continue;
|
||||
|
||||
// Determine source and target cells
|
||||
const sourceCellId = portA.cardId ?? portA.deviceId;
|
||||
const targetCellId = portZ.cardId ?? portZ.deviceId;
|
||||
|
||||
edges.push(
|
||||
createEdgeConfig(
|
||||
line.id,
|
||||
sourceCellId,
|
||||
line.portAId,
|
||||
targetCellId,
|
||||
line.portZId,
|
||||
line.status,
|
||||
line.medium,
|
||||
line.lineStyle,
|
||||
line.name,
|
||||
routerType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal file
64
frontend/src/features/schema/helpers/portSideResolver.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { SchemaData, Port } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
|
||||
interface DevicePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function resolvePortSides(
|
||||
data: SchemaData,
|
||||
devicePositions: Map<string, DevicePosition>,
|
||||
): Map<string, 'left' | 'right'> {
|
||||
const portSideMap = new Map<string, 'left' | 'right'>();
|
||||
const device = (id: string) => data.devices.find((d) => d.id === id);
|
||||
|
||||
for (const port of data.ports) {
|
||||
const dev = device(port.deviceId);
|
||||
if (!dev) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cross devices: L ports → left, S ports → right (hardcoded)
|
||||
if (
|
||||
dev.category === DeviceCategory.CrossOptical ||
|
||||
dev.category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
const side = port.slotName === 'L' || port.name.startsWith('L') ? 'left' : 'right';
|
||||
portSideMap.set(port.id, side);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other devices, determine side based on connected device position
|
||||
const connectedLine = data.lines.find(
|
||||
(l) => l.portAId === port.id || l.portZId === port.id,
|
||||
);
|
||||
|
||||
if (!connectedLine) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
const otherPortId =
|
||||
connectedLine.portAId === port.id
|
||||
? connectedLine.portZId
|
||||
: connectedLine.portAId;
|
||||
const otherPort = data.ports.find((p: Port) => p.id === otherPortId);
|
||||
if (!otherPort) {
|
||||
portSideMap.set(port.id, port.side);
|
||||
continue;
|
||||
}
|
||||
|
||||
const thisPos = devicePositions.get(port.deviceId);
|
||||
const otherPos = devicePositions.get(otherPort.deviceId);
|
||||
|
||||
if (thisPos && otherPos) {
|
||||
portSideMap.set(port.id, otherPos.x < thisPos.x ? 'left' : 'right');
|
||||
} else {
|
||||
portSideMap.set(port.id, port.side);
|
||||
}
|
||||
}
|
||||
|
||||
return portSideMap;
|
||||
}
|
||||
193
frontend/src/features/schema/layout/autoLayout.ts
Normal file
193
frontend/src/features/schema/layout/autoLayout.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import type { Device, Site, SchemaData } from '../../../types/index.ts';
|
||||
import { DeviceCategory } from '../../../types/index.ts';
|
||||
import { LAYER_MAPPING, MAX_CROSS_PER_LAYER } from '../../../constants/layerMapping.ts';
|
||||
import {
|
||||
CROSS_WIDTH,
|
||||
CROSS_HEIGHT,
|
||||
SPLICE_SIZE,
|
||||
DEVICE_MIN_WIDTH,
|
||||
DEVICE_MIN_HEIGHT,
|
||||
SITE_HEADER_HEIGHT,
|
||||
SITE_PADDING,
|
||||
SITE_MIN_WIDTH,
|
||||
LAYER_GAP,
|
||||
DEVICE_GAP,
|
||||
LAYER_PADDING_X,
|
||||
} from '../../../constants/sizes.ts';
|
||||
|
||||
export interface LayoutResult {
|
||||
nodePositions: Map<string, { x: number; y: number; width: number; height: number }>;
|
||||
sitePositions: Map<string, { x: number; y: number; width: number; height: number }>;
|
||||
}
|
||||
|
||||
function getDeviceSize(device: Device, portCount: number): { width: number; height: number } {
|
||||
if (
|
||||
device.category === DeviceCategory.CrossOptical ||
|
||||
device.category === DeviceCategory.CrossCopper
|
||||
) {
|
||||
return { width: CROSS_WIDTH, height: CROSS_HEIGHT };
|
||||
}
|
||||
|
||||
// Splice device detection by name/marking
|
||||
if (device.name.toLowerCase().includes('муфта') || device.marking.toLowerCase().includes('мток')) {
|
||||
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
|
||||
}
|
||||
|
||||
// Dynamic height based on port count
|
||||
const portHeight = Math.max(portCount * 22, 60);
|
||||
return {
|
||||
width: DEVICE_MIN_WIDTH,
|
||||
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30),
|
||||
};
|
||||
}
|
||||
|
||||
function getLayerForDevice(device: Device): number {
|
||||
for (const [layer, categories] of Object.entries(LAYER_MAPPING)) {
|
||||
if (categories.includes(device.category)) {
|
||||
return parseInt(layer, 10);
|
||||
}
|
||||
}
|
||||
return 7; // default to "unknown" layer
|
||||
}
|
||||
|
||||
function getSiteDevices(siteId: string, data: SchemaData): Device[] {
|
||||
return data.devices.filter((d) => d.siteId === siteId);
|
||||
}
|
||||
|
||||
function layoutDevicesInSite(
|
||||
devices: Device[],
|
||||
data: SchemaData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
): { positions: Map<string, { x: number; y: number; width: number; height: number }>; totalWidth: number; totalHeight: number } {
|
||||
const positions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
|
||||
// Assign devices to layers
|
||||
const layers = new Map<number, { device: Device; size: { width: number; height: number } }[]>();
|
||||
for (const device of devices) {
|
||||
const layer = getLayerForDevice(device);
|
||||
const portCount = data.ports.filter((p) => p.deviceId === device.id && !p.cardId).length;
|
||||
const size = getDeviceSize(device, portCount);
|
||||
const existing = layers.get(layer);
|
||||
if (existing) {
|
||||
existing.push({ device, size });
|
||||
} else {
|
||||
layers.set(layer, [{ device, size }]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle overflow: layer 1 with >MAX_CROSS crosses → overflow to layer 8
|
||||
const layer1 = layers.get(1);
|
||||
if (layer1 && layer1.length > MAX_CROSS_PER_LAYER) {
|
||||
const overflow = layer1.splice(MAX_CROSS_PER_LAYER);
|
||||
const layer8 = layers.get(8) ?? [];
|
||||
layer8.push(...overflow);
|
||||
layers.set(8, layer8);
|
||||
}
|
||||
|
||||
// Calculate layer Y positions (skip empty layers)
|
||||
const sortedLayers = Array.from(layers.entries()).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let currentY = startY;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (const [, layerDevices] of sortedLayers) {
|
||||
let currentX = startX + LAYER_PADDING_X;
|
||||
let layerMaxHeight = 0;
|
||||
|
||||
for (const { device, size } of layerDevices) {
|
||||
positions.set(device.id, {
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
currentX += size.width + DEVICE_GAP;
|
||||
layerMaxHeight = Math.max(layerMaxHeight, size.height);
|
||||
}
|
||||
|
||||
maxWidth = Math.max(maxWidth, currentX - startX);
|
||||
currentY += layerMaxHeight + LAYER_GAP;
|
||||
}
|
||||
|
||||
const totalHeight = currentY - startY;
|
||||
const totalWidth = Math.max(maxWidth + LAYER_PADDING_X, SITE_MIN_WIDTH);
|
||||
|
||||
return { positions, totalWidth, totalHeight };
|
||||
}
|
||||
|
||||
export function autoLayout(data: SchemaData): LayoutResult {
|
||||
const nodePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
const sitePositions = new Map<string, { x: number; y: number; width: number; height: number }>();
|
||||
|
||||
// Separate root sites and child sites
|
||||
const rootSites = data.sites.filter((s: Site) => !s.parentSiteId);
|
||||
const childSites = data.sites.filter((s: Site) => s.parentSiteId);
|
||||
|
||||
let siteX = 50;
|
||||
|
||||
for (const site of rootSites) {
|
||||
const siteDevices = getSiteDevices(site.id, data);
|
||||
|
||||
const contentStartY = SITE_HEADER_HEIGHT + SITE_PADDING;
|
||||
const { positions, totalWidth, totalHeight } = layoutDevicesInSite(
|
||||
siteDevices,
|
||||
data,
|
||||
siteX + SITE_PADDING,
|
||||
contentStartY + 50, // offset for site positioning
|
||||
);
|
||||
|
||||
// Add device positions
|
||||
for (const [deviceId, pos] of positions.entries()) {
|
||||
nodePositions.set(deviceId, pos);
|
||||
}
|
||||
|
||||
// Handle child sites at bottom of parent
|
||||
const siteChildren = childSites.filter((cs) => cs.parentSiteId === site.id);
|
||||
let childSiteExtraHeight = 0;
|
||||
let childX = siteX + SITE_PADDING;
|
||||
|
||||
for (const childSite of siteChildren) {
|
||||
const childDevices = getSiteDevices(childSite.id, data);
|
||||
const childContentStartY = contentStartY + totalHeight + 50;
|
||||
|
||||
const childLayout = layoutDevicesInSite(
|
||||
childDevices,
|
||||
data,
|
||||
childX + SITE_PADDING,
|
||||
childContentStartY + SITE_HEADER_HEIGHT + SITE_PADDING,
|
||||
);
|
||||
|
||||
for (const [deviceId, pos] of childLayout.positions.entries()) {
|
||||
nodePositions.set(deviceId, pos);
|
||||
}
|
||||
|
||||
const childSiteWidth = Math.max(childLayout.totalWidth + SITE_PADDING * 2, 250);
|
||||
const childSiteHeight = childLayout.totalHeight + SITE_HEADER_HEIGHT + SITE_PADDING * 2;
|
||||
|
||||
sitePositions.set(childSite.id, {
|
||||
x: childX,
|
||||
y: childContentStartY,
|
||||
width: childSiteWidth,
|
||||
height: childSiteHeight,
|
||||
});
|
||||
|
||||
childX += childSiteWidth + DEVICE_GAP;
|
||||
childSiteExtraHeight = Math.max(childSiteExtraHeight, childSiteHeight + SITE_PADDING);
|
||||
}
|
||||
|
||||
const siteWidth = Math.max(totalWidth + SITE_PADDING * 2, childX - siteX);
|
||||
const siteHeight = totalHeight + contentStartY + childSiteExtraHeight + SITE_PADDING;
|
||||
|
||||
sitePositions.set(site.id, {
|
||||
x: siteX,
|
||||
y: 50,
|
||||
width: siteWidth,
|
||||
height: siteHeight,
|
||||
});
|
||||
|
||||
siteX += siteWidth + 80;
|
||||
}
|
||||
|
||||
return { nodePositions, sitePositions };
|
||||
}
|
||||
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal file
44
frontend/src/features/schema/nodes/CardNode.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface CardNodeData {
|
||||
slotName: string;
|
||||
networkName: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function CardNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as CardNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
borderTop: `1.5px solid ${colors.border}`,
|
||||
borderBottom: `1.5px solid ${colors.border}`,
|
||||
borderLeft: `5px solid ${colors.border}`,
|
||||
borderRight: `5px solid ${colors.border}`,
|
||||
borderRadius: 0,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{data.slotName}:{data.networkName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal file
83
frontend/src/features/schema/nodes/CrossDeviceNode.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { CROSS_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface CrossDeviceData {
|
||||
name: string;
|
||||
networkName: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function CrossDeviceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as CrossDeviceData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
const r = CROSS_BORDER_RADIUS;
|
||||
const w = size.width;
|
||||
const h = size.height;
|
||||
|
||||
// Asymmetric rounding: top-left and bottom-right rounded
|
||||
const path = `
|
||||
M ${r} 0
|
||||
L ${w} 0
|
||||
L ${w} ${h - r}
|
||||
Q ${w} ${h} ${w - r} ${h}
|
||||
L 0 ${h}
|
||||
L 0 ${r}
|
||||
Q 0 0 ${r} 0
|
||||
Z
|
||||
`;
|
||||
|
||||
return (
|
||||
<div style={{ width: w, height: h, position: 'relative' }}>
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill={colors.fill}
|
||||
stroke={colors.border}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px 6px',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 4, wordBreak: 'break-word' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
{data.networkName && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
|
||||
)}
|
||||
{data.marking && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
{data.id1 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal file
80
frontend/src/features/schema/nodes/DeviceNode.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { DEVICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import { DeviceGroup, type EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface DeviceNodeData {
|
||||
name: string;
|
||||
networkName: string;
|
||||
ipAddress: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
group: DeviceGroup;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function DeviceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as DeviceNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
const isActive = data.group === DeviceGroup.Active;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: DEVICE_BORDER_RADIUS,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
padding: '6px 8px',
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
marginBottom: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<>
|
||||
{data.networkName && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
|
||||
)}
|
||||
{data.ipAddress && (
|
||||
<div style={{ color: '#1890ff', fontSize: 9 }}>{data.ipAddress}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{data.marking && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
{data.id1 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
|
||||
)}
|
||||
{data.id2 && (
|
||||
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id2}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal file
60
frontend/src/features/schema/nodes/SiteNode.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { SITE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface SiteNodeData {
|
||||
name: string;
|
||||
address: string;
|
||||
erpCode: string;
|
||||
code1C: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function SiteNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as SiteNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `3.87px solid ${colors.border}`,
|
||||
borderRadius: 0,
|
||||
background: 'transparent',
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: SITE_HEADER_HEIGHT,
|
||||
background: '#1a1a2e',
|
||||
color: '#ffffff',
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{data.address}
|
||||
</div>
|
||||
<div style={{ opacity: 0.7, fontSize: 10 }}>
|
||||
ERP: {data.erpCode} | 1С: {data.code1C}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal file
46
frontend/src/features/schema/nodes/SpliceNode.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import type { Node } from '@antv/x6';
|
||||
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
|
||||
import { SPLICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
|
||||
import type { EntityStatus } from '../../../types/index.ts';
|
||||
|
||||
interface SpliceNodeData {
|
||||
name: string;
|
||||
marking: string;
|
||||
id1: string;
|
||||
id2: string;
|
||||
status: EntityStatus;
|
||||
}
|
||||
|
||||
export function SpliceNode({ node }: { node: Node }) {
|
||||
const data = node.getData() as SpliceNodeData;
|
||||
const colors = STATUS_COLORS[data.status];
|
||||
const size = node.getSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: SPLICE_BORDER_RADIUS,
|
||||
background: colors.fill,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'center',
|
||||
padding: 4,
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 2, wordBreak: 'break-word' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
{data.marking && (
|
||||
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/features/schema/ports/portConfig.ts
Normal file
63
frontend/src/features/schema/ports/portConfig.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { PORT_RADIUS } from '../../../constants/sizes.ts';
|
||||
|
||||
export const portGroups = {
|
||||
left: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: PORT_RADIUS,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: {
|
||||
name: 'left',
|
||||
args: { x: -8, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
right: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: PORT_RADIUS,
|
||||
magnet: true,
|
||||
stroke: '#8c8c8c',
|
||||
strokeWidth: 1,
|
||||
fill: '#fff',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
position: {
|
||||
name: 'right',
|
||||
args: { x: 8, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createPortItem(
|
||||
portId: string,
|
||||
side: 'left' | 'right',
|
||||
label: string,
|
||||
labelColor?: string,
|
||||
) {
|
||||
return {
|
||||
id: portId,
|
||||
group: side,
|
||||
attrs: {
|
||||
text: {
|
||||
text: label,
|
||||
fontSize: 8,
|
||||
fill: labelColor || '#595959',
|
||||
},
|
||||
circle: {
|
||||
fill: labelColor || '#fff',
|
||||
stroke: labelColor || '#8c8c8c',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user