Files
test-x6/frontend/src/features/schema/SchemaCanvas.tsx
Alina b877750ecf
All checks were successful
continuous-integration/drone/push Build is passing
feat: функционал скрытия подписей + fix: build output в корневой dist/
- Свитч «Подписи» в тулбаре теперь скрывает/показывает подписи
  на линиях и портах через visibility toggle
- vite outDir перенесён в ../dist для совместимости с react.Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:58:28 +03:00

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>
);
}