All checks were successful
continuous-integration/drone/push Build is passing
- Свитч «Подписи» в тулбаре теперь скрывает/показывает подписи на линиях и портах через visibility toggle - vite outDir перенесён в ../dist для совместимости с react.Dockerfile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
7.5 KiB
TypeScript
268 lines
7.5 KiB
TypeScript
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: grid
|
|
useEffect(() => {
|
|
const graph = useSchemaStore.getState().graph;
|
|
if (!graph) return;
|
|
|
|
if (displaySettings.showGrid) {
|
|
graph.showGrid();
|
|
} else {
|
|
graph.hideGrid();
|
|
}
|
|
}, [displaySettings.showGrid]);
|
|
|
|
// Sync display settings: labels
|
|
useEffect(() => {
|
|
const graph = useSchemaStore.getState().graph;
|
|
if (!graph) return;
|
|
|
|
const show = displaySettings.showLabels;
|
|
|
|
// Toggle edge labels
|
|
for (const edge of graph.getEdges()) {
|
|
const labels = edge.getLabels();
|
|
if (labels.length > 0) {
|
|
edge.setLabels(
|
|
labels.map((label) => ({
|
|
...label,
|
|
attrs: {
|
|
...label.attrs,
|
|
label: {
|
|
...(label.attrs?.label as Record<string, unknown>),
|
|
visibility: show ? 'visible' : 'hidden',
|
|
},
|
|
rect: {
|
|
...(label.attrs?.rect as Record<string, unknown>),
|
|
visibility: show ? 'visible' : 'hidden',
|
|
},
|
|
},
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Toggle port labels
|
|
for (const node of graph.getNodes()) {
|
|
const ports = node.getPorts();
|
|
for (const port of ports) {
|
|
node.setPortProp(port.id!, 'attrs/text/visibility', show ? 'visible' : 'hidden');
|
|
}
|
|
}
|
|
}, [displaySettings.showLabels]);
|
|
|
|
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>
|
|
);
|
|
}
|