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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m58s
This commit is contained in:
+71
-19
@@ -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 }) {
|
function GraphView({ servers, connections }) {
|
||||||
const [nodePositions, setNodePositions] = useState({});
|
const [nodePositions, setNodePositions] = useState({});
|
||||||
@@ -6,6 +7,47 @@ function GraphView({ servers, connections }) {
|
|||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [hoveredLinkId, setHoveredLinkId] = useState(null);
|
const [hoveredLinkId, setHoveredLinkId] = useState(null);
|
||||||
const svgRef = useRef(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) {
|
if (servers.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -36,11 +78,11 @@ function GraphView({ servers, connections }) {
|
|||||||
|
|
||||||
// Обработчик начала перетаскивания
|
// Обработчик начала перетаскивания
|
||||||
const handleMouseDown = (e, serverIp) => {
|
const handleMouseDown = (e, serverIp) => {
|
||||||
const svg = svgRef.current;
|
const target = gRef.current || svgRef.current;
|
||||||
const pt = svg.createSVGPoint();
|
const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint();
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX;
|
||||||
pt.y = e.clientY;
|
pt.y = e.clientY;
|
||||||
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
|
const svgP = pt.matrixTransform(target.getScreenCTM().inverse());
|
||||||
|
|
||||||
const currentPos = nodePositions[serverIp] || getInitialNodePosition(
|
const currentPos = nodePositions[serverIp] || getInitialNodePosition(
|
||||||
servers.findIndex(s => s.ip === serverIp),
|
servers.findIndex(s => s.ip === serverIp),
|
||||||
@@ -57,12 +99,11 @@ function GraphView({ servers, connections }) {
|
|||||||
// Обработчик перетаскивания
|
// Обработчик перетаскивания
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e) => {
|
||||||
if (!draggedNode) return;
|
if (!draggedNode) return;
|
||||||
|
const target = gRef.current || svgRef.current;
|
||||||
const svg = svgRef.current;
|
const pt = (gRef.current?.ownerSVGElement || svgRef.current).createSVGPoint();
|
||||||
const pt = svg.createSVGPoint();
|
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX;
|
||||||
pt.y = e.clientY;
|
pt.y = e.clientY;
|
||||||
const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
|
const svgP = pt.matrixTransform(target.getScreenCTM().inverse());
|
||||||
|
|
||||||
setNodePositions(prev => ({
|
setNodePositions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -148,6 +189,7 @@ function GraphView({ servers, connections }) {
|
|||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
<g ref={gRef}>
|
||||||
{/* Связи */}
|
{/* Связи */}
|
||||||
{connections.map((connection, index) => {
|
{connections.map((connection, index) => {
|
||||||
const fromServer = servers.find(s => s.ip === connection.from);
|
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 id = `${connection.from}-${connection.to}-${index}`;
|
||||||
const isHovered = hoveredLinkId === id;
|
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 (
|
return (
|
||||||
<g key={`link-${index}`}
|
<g key={`link-${index}`}
|
||||||
onMouseEnter={() => setHoveredLinkId(id)}
|
onMouseEnter={() => setHoveredLinkId(id)}
|
||||||
onMouseLeave={() => setHoveredLinkId(null)}
|
onMouseLeave={() => setHoveredLinkId(null)}
|
||||||
>
|
>
|
||||||
{/* Линия связи */}
|
{/* Линия связи */}
|
||||||
<line
|
<path
|
||||||
x1={fromPos.x}
|
d={`M ${fromPos.x} ${fromPos.y} Q ${cx} ${cy} ${toPos.x} ${toPos.y}`}
|
||||||
y1={fromPos.y}
|
fill="none"
|
||||||
x2={toPos.x}
|
|
||||||
y2={toPos.y}
|
|
||||||
stroke={style.color}
|
stroke={style.color}
|
||||||
strokeWidth={isHovered ? style.width + 1 : style.width}
|
strokeWidth={isHovered ? style.width + 1 : style.width}
|
||||||
strokeDasharray={style.dash}
|
strokeDasharray={style.dash}
|
||||||
@@ -182,8 +233,8 @@ function GraphView({ servers, connections }) {
|
|||||||
|
|
||||||
{/* Подпись связи */}
|
{/* Подпись связи */}
|
||||||
<rect
|
<rect
|
||||||
x={(fromPos.x + toPos.x) / 2 - 40}
|
x={mx - 50}
|
||||||
y={(fromPos.y + toPos.y) / 2 - 15}
|
y={my - 35}
|
||||||
width="80"
|
width="80"
|
||||||
height="30"
|
height="30"
|
||||||
rx="15"
|
rx="15"
|
||||||
@@ -192,8 +243,8 @@ function GraphView({ servers, connections }) {
|
|||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={(fromPos.x + toPos.x) / 2}
|
x={mx - 10}
|
||||||
y={(fromPos.y + toPos.y) / 2}
|
y={my - 18}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="11"
|
fontSize="11"
|
||||||
fill="#374151"
|
fill="#374151"
|
||||||
@@ -202,8 +253,8 @@ function GraphView({ servers, connections }) {
|
|||||||
{connection.tunnelType}
|
{connection.tunnelType}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={(fromPos.x + toPos.x) / 2}
|
x={mx}
|
||||||
y={(fromPos.y + toPos.y) / 2 + 15}
|
y={my - 3}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="9"
|
fontSize="9"
|
||||||
fill="#6b7280"
|
fill="#6b7280"
|
||||||
@@ -329,6 +380,7 @@ function GraphView({ servers, connections }) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Доп. определения добавлены выше */}
|
{/* Доп. определения добавлены выше */}
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user