diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx index a315bd8..3ddb1f5 100644 --- a/frontend/src/GraphView.jsx +++ b/frontend/src/GraphView.jsx @@ -1,4 +1,5 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import * as d3 from 'd3'; function GraphView({ servers, connections }) { const [nodePositions, setNodePositions] = useState({}); @@ -6,6 +7,47 @@ function GraphView({ servers, connections }) { const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [hoveredLinkId, setHoveredLinkId] = useState(null); const svgRef = useRef(null); + const gRef = useRef(null); + + // Автоматическая раскладка (force-directed) + useEffect(() => { + if (!servers || servers.length === 0) return; + + const nodes = servers.map((s) => ({ id: s.ip })); + const links = (connections || []).map((c) => ({ source: c.from, target: c.to, type: c.tunnelType })); + + const distanceForType = (t) => ({ GRE: 200, IPSec: 220, WireGuard: 180, OpenVPN: 200 }[t] || 210); + + const simulation = d3 + .forceSimulation(nodes) + .force('link', d3.forceLink(links).id((d) => d.id).distance((d) => distanceForType(d.type))) + .force('charge', d3.forceManyBody().strength(-500)) + .force('collide', d3.forceCollide(70)) + .force('center', d3.forceCenter(450, 300)) + .stop(); + + for (let i = 0; i < 300; i += 1) simulation.tick(); + + const pos = {}; + nodes.forEach((n) => { + pos[n.id] = { x: n.x, y: n.y }; + }); + setNodePositions(pos); + }, [servers, connections]); + + // Зум/панорамирование + useEffect(() => { + const svg = d3.select(svgRef.current); + const g = d3.select(gRef.current); + svg.call( + d3 + .zoom() + .scaleExtent([0.5, 2]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + }) + ); + }, []); if (servers.length === 0) { return ( @@ -36,11 +78,11 @@ function GraphView({ servers, connections }) { // Обработчик начала перетаскивания const handleMouseDown = (e, serverIp) => { - const svg = svgRef.current; - const pt = svg.createSVGPoint(); + const target = gRef.current || svgRef.current; + const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY; - const svgP = pt.matrixTransform(svg.getScreenCTM().inverse()); + const svgP = pt.matrixTransform(target.getScreenCTM().inverse()); const currentPos = nodePositions[serverIp] || getInitialNodePosition( servers.findIndex(s => s.ip === serverIp), @@ -57,12 +99,11 @@ function GraphView({ servers, connections }) { // Обработчик перетаскивания const handleMouseMove = (e) => { if (!draggedNode) return; - - const svg = svgRef.current; - const pt = svg.createSVGPoint(); + const target = gRef.current || svgRef.current; + const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY; - const svgP = pt.matrixTransform(svg.getScreenCTM().inverse()); + const svgP = pt.matrixTransform(target.getScreenCTM().inverse()); setNodePositions(prev => ({ ...prev, @@ -148,6 +189,7 @@ function GraphView({ servers, connections }) { + {/* Связи */} {connections.map((connection, index) => { const fromServer = servers.find(s => s.ip === connection.from); @@ -161,17 +203,26 @@ function GraphView({ servers, connections }) { const id = `${connection.from}-${connection.to}-${index}`; const isHovered = hoveredLinkId === id; + // Кривая линия с небольшим отступом от прямой для читаемости + const mx = (fromPos.x + toPos.x) / 2; + const my = (fromPos.y + toPos.y) / 2; + const dx = toPos.x - fromPos.x; + const dy = toPos.y - fromPos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = (-dy / len) * 30; // перпендикуляр, 30px + const ny = (dx / len) * 30; + const cx = mx + nx; + const cy = my + ny; + return ( setHoveredLinkId(id)} onMouseLeave={() => setHoveredLinkId(null)} > {/* Линия связи */} - );