feat: Implement force-directed layout and zoom functionality in GraphView for enhanced visualization and interactivity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m58s

This commit is contained in:
2025-08-08 15:12:29 +07:00
parent 0e81f36200
commit 26152b2127
+71 -19
View File
@@ -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 }) {
</feMerge>
</filter>
</defs>
<g ref={gRef}>
{/* Связи */}
{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 (
<g key={`link-${index}`}
onMouseEnter={() => setHoveredLinkId(id)}
onMouseLeave={() => setHoveredLinkId(null)}
>
{/* Линия связи */}
<line
x1={fromPos.x}
y1={fromPos.y}
x2={toPos.x}
y2={toPos.y}
<path
d={`M ${fromPos.x} ${fromPos.y} Q ${cx} ${cy} ${toPos.x} ${toPos.y}`}
fill="none"
stroke={style.color}
strokeWidth={isHovered ? style.width + 1 : style.width}
strokeDasharray={style.dash}
@@ -182,8 +233,8 @@ function GraphView({ servers, connections }) {
{/* Подпись связи */}
<rect
x={(fromPos.x + toPos.x) / 2 - 40}
y={(fromPos.y + toPos.y) / 2 - 15}
x={mx - 50}
y={my - 35}
width="80"
height="30"
rx="15"
@@ -192,8 +243,8 @@ function GraphView({ servers, connections }) {
strokeWidth="1"
/>
<text
x={(fromPos.x + toPos.x) / 2}
y={(fromPos.y + toPos.y) / 2}
x={mx - 10}
y={my - 18}
textAnchor="middle"
fontSize="11"
fill="#374151"
@@ -202,8 +253,8 @@ function GraphView({ servers, connections }) {
{connection.tunnelType}
</text>
<text
x={(fromPos.x + toPos.x) / 2}
y={(fromPos.y + toPos.y) / 2 + 15}
x={mx}
y={my - 3}
textAnchor="middle"
fontSize="9"
fill="#6b7280"
@@ -329,6 +380,7 @@ function GraphView({ servers, connections }) {
})}
{/* Доп. определения добавлены выше */}
</g>
</svg>
</div>
);