import React, { useState, useRef, useEffect } from 'react'; import * as d3 from 'd3'; import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react'; function GraphView({ servers, connections }) { // Debug helper (enable/disable via window.GRAPH_DEBUG = true/false in console) const isDebug = typeof window !== 'undefined' ? (window.GRAPH_DEBUG !== false) : true; const dbg = (...args) => { if (isDebug && typeof console !== 'undefined') console.log('[GraphView]', ...args); }; const [nodePositions, setNodePositions] = useState({}); const [draggedNode, setDraggedNode] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [hoveredLinkId, setHoveredLinkId] = useState(null); const containerRef = useRef(null); const svgRef = useRef(null); const gRef = useRef(null); const zoomRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); // Размеры карточки узла const NODE_WIDTH = 160; const NODE_HEIGHT = 74; // Автоматическая раскладка (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); // После раскладки подгоняем граф по области видимости try { const svgEl = svgRef.current; const box = svgEl.getBoundingClientRect(); const padding = 40; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; nodes.forEach((n) => { minX = Math.min(minX, n.x - NODE_WIDTH / 2); minY = Math.min(minY, n.y - NODE_HEIGHT / 2); maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2); }); const contentW = Math.max(1, maxX - minX); const contentH = Math.max(1, maxY - minY); const scale = Math.max(0.5, Math.min(1.4, Math.min((box.width - padding) / contentW, (box.height - padding) / contentH))); const tx = (box.width - scale * (minX + maxX)) / 2; const ty = (box.height - scale * (minY + maxY)) / 2; const t = d3.zoomIdentity.translate(tx, ty).scale(scale); d3.select(svgEl).transition().duration(350).call(zoomRef.current.transform, t); } catch (e) { // ignore } }, [servers, connections]); // Зум/панорамирование useEffect(() => { const svg = d3.select(svgRef.current); const g = d3.select(gRef.current); const width = svgRef.current?.clientWidth || 900; const height = svgRef.current?.clientHeight || 600; zoomRef.current = d3 .zoom() .scaleExtent([0.3, 3]) .extent([[0, 0], [width, height]]) // Разрешаем панораму в широких пределах .translateExtent([[-10000, -10000], [10000, 10000]]) .on('zoom', (event) => { g.attr('transform', event.transform); // dbg('onZoom', { k: event.transform.k, x: event.transform.x, y: event.transform.y }); }); svg.call(zoomRef.current); dbg('zoom initialized', { width, height }); }, []); // Контролы масштабирования const zoomBy = (factor) => { if (!zoomRef.current || !svgRef.current) return; const svgEl = svgRef.current; const current = d3.zoomTransform(svgEl); const cx = svgEl.clientWidth / 2; const cy = svgEl.clientHeight / 2; const minK = 0.3; const maxK = 3; let nextK = current.k * factor; if (nextK < minK) nextK = minK; if (nextK > maxK) nextK = maxK; dbg('zoomBy click', { factor, current: { k: current.k, x: current.x, y: current.y }, nextK, center: { cx, cy } }); const svgSel = d3.select(svgEl); svgSel.interrupt(); svgSel .transition() .duration(220) .call(zoomRef.current.scaleTo, nextK, [cx, cy]) .on('end', () => { const nt = d3.zoomTransform(svgEl); dbg('zoomBy end', { next: { k: nt.k, x: nt.x, y: nt.y } }); }); }; const fitToView = () => { const svgEl = svgRef.current; if (!svgEl) return; const box = svgEl.getBoundingClientRect(); const padding = 40; const positions = nodePositions; const ids = Object.keys(positions); if (ids.length === 0) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; ids.forEach((id) => { const p = positions[id]; minX = Math.min(minX, p.x - NODE_WIDTH / 2); minY = Math.min(minY, p.y - NODE_HEIGHT / 2); maxX = Math.max(maxX, p.x + NODE_WIDTH / 2); maxY = Math.max(maxY, p.y + NODE_HEIGHT / 2); }); const contentW = Math.max(1, maxX - minX); const contentH = Math.max(1, maxY - minY); const scale = Math.max(0.5, Math.min(1.6, Math.min((box.width - padding) / contentW, (box.height - padding) / contentH))); const tx = (box.width - scale * (minX + maxX)) / 2; const ty = (box.height - scale * (minY + maxY)) / 2; const t = d3.zoomIdentity.translate(tx, ty).scale(scale); d3.select(svgEl).transition().duration(250).call(zoomRef.current.transform, t); }; // Полноэкранный режим useEffect(() => { const handler = () => setIsFullscreen(Boolean(document.fullscreenElement)); document.addEventListener('fullscreenchange', handler); return () => document.removeEventListener('fullscreenchange', handler); }, []); const toggleFullscreen = () => { const el = containerRef.current; if (!el) return; dbg('toggleFullscreen', { isFullscreen: Boolean(document.fullscreenElement) }); if (!document.fullscreenElement) { el.requestFullscreen?.(); } else { document.exitFullscreen?.(); } }; if (servers.length === 0) { return (
Добавьте серверы, чтобы увидеть граф связей