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 (

Нет серверов для отображения

Добавьте серверы, чтобы увидеть граф связей

); } // Инициализация позиций узлов при первом рендере const getInitialNodePosition = (index, total) => { if (nodePositions[servers[index]?.ip]) { return nodePositions[servers[index].ip]; } const cols = Math.ceil(Math.sqrt(total)); const row = Math.floor(index / cols); const col = index % cols; const spacing = 180; return { x: 120 + col * spacing, y: 120 + row * spacing }; }; // Обработчик начала перетаскивания const handleMouseDown = (e, serverIp) => { 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(target.getScreenCTM().inverse()); const currentPos = nodePositions[serverIp] || getInitialNodePosition( servers.findIndex(s => s.ip === serverIp), servers.length ); setDragOffset({ x: svgP.x - currentPos.x, y: svgP.y - currentPos.y }); setDraggedNode(serverIp); }; // Обработчик перетаскивания const handleMouseMove = (e) => { if (!draggedNode) return; 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(target.getScreenCTM().inverse()); setNodePositions(prev => ({ ...prev, [draggedNode]: { x: svgP.x - dragOffset.x, y: svgP.y - dragOffset.y } })); }; // Обработчик окончания перетаскивания const handleMouseUp = () => { setDraggedNode(null); }; // Получение позиции узла const getNodePosition = (serverIp) => { return nodePositions[serverIp] || getInitialNodePosition( servers.findIndex(s => s.ip === serverIp), servers.length ); }; // Цвета для разных стран const getCountryColor = (country) => { const colors = { 'RU': '#dc2626', // красный 'US': '#2563eb', // синий 'DE': '#059669', // зеленый 'SE': '#7c3aed', // фиолетовый 'NL': '#ea580c', // оранжевый 'SG': '#0891b2' // голубой }; return colors[country] || '#6b7280'; }; // Цвета/стили для разных типов туннелей const getTunnelStyle = (tunnelType) => { const map = { GRE: { color: '#206bc4', dash: '0', width: 3 }, IPSec: { color: '#f59f00', dash: '6,4', width: 3 }, WireGuard: { color: '#2fb344', dash: '0', width: 4 }, OpenVPN: { color: '#be4bdb', dash: '3,3', width: 3 }, }; return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 }; }; // Флаг страны по ISO-2 с поддержкой нестандартных кодов из списка (SWE->SE) const getFlagEmoji = (code) => { if (!code) return ''; const map = { SWE: 'SE', UK: 'GB' }; const ccRaw = String(code).trim().toUpperCase(); const cc = (map[ccRaw] || ccRaw).slice(0, 2); if (cc.length !== 2) return ccRaw; return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt())); }; return (
{/* Лёгкая сетка фона */} {/* Определение стрелок под цвет канала */} {Array.from(new Set(connections.map(c => c.tunnelType))).map((t) => { const style = getTunnelStyle(t); return ( ); })} {/* Свечение для выделения */} {/* Тень карточки */} {/* Тень для ярлыков ссылок */} {/* Подложка сетки */} {/* Связи */} {connections.map((connection, index) => { const fromServer = servers.find(s => s.ip === connection.from); const toServer = servers.find(s => s.ip === connection.to); if (!fromServer || !toServer) return null; const fromPos = getNodePosition(connection.from); const toPos = getNodePosition(connection.to); const style = getTunnelStyle(connection.tunnelType); 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)} > {/* Линия связи */} {/* Подпись связи: ярлык с подложкой и halo */} {(() => { const labelW = 140; const labelH = 38; const lx = cx - labelW / 2; const ly = cy - labelH / 2 - 4; // слегка выше вершины return ( {/* Halo для заголовка */} {connection.tunnelType} {/* Линия IP с более мелким шрифтом */} {connection.ipA} ⇄ {connection.ipB} ); })()} ); })} {/* Узлы (серверы) */} {servers.map((server) => { const pos = getNodePosition(server.ip); const countryColor = getCountryColor(server.country); const isDragging = draggedNode === server.ip; return ( {/* Карточка узла */} handleMouseDown(e, server.ip)} /> {/* Плашка страны */} {(() => { const badgeX = pos.x - NODE_WIDTH / 2 + 8; const badgeY = pos.y - NODE_HEIGHT / 2 - 10; const flag = getFlagEmoji(server.country); return ( {/* Рендер флага через foreignObject как в HTML (надёжнее для Windows/Chrome) */}
{flag}
{String(server.country).toUpperCase().slice(0,3)}
); })()} {/* Название */} {server.dns} {/* IP */} {server.ip} {/* Провайдер */} {server.provider}
); })} {/* Доп. определения добавлены выше */}
{/* Контролы масштабирования */}
); } export default GraphView;