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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user