feat: Integrate @xyflow/react for graph visualization in GraphView component, update package.json and package-lock.json to include new dependencies and remove unused ones for improved performance and maintainability
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m21s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m21s
This commit is contained in:
+163
-591
@@ -1,226 +1,154 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize } from '@tabler/icons-react';
|
||||
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
addEdge,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
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 flowRef = useRef(null);
|
||||
const instanceRef = useRef(null);
|
||||
|
||||
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 [transformState, setTransformState] = useState({ k: 1, x: 0, y: 0 });
|
||||
const [isPanningMode, setIsPanningMode] = useState(false);
|
||||
const panModeRef = useRef(false);
|
||||
const getTunnelStyle = useCallback((tunnelType) => {
|
||||
const map = {
|
||||
GRE: { color: '#206bc4', dash: '0', width: 2.5 },
|
||||
IPSec: { color: '#f59f00', dash: '6,4', width: 2.5 },
|
||||
WireGuard: { color: '#2fb344', dash: '0', width: 3.2 },
|
||||
OpenVPN: { color: '#be4bdb', dash: '3,3', width: 2.5 },
|
||||
};
|
||||
return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 };
|
||||
}, []);
|
||||
|
||||
const ZOOM_MIN = 0.2;
|
||||
const ZOOM_MAX = 4;
|
||||
const ZOOM_STEP = 0.01;
|
||||
const getCountryColor = useCallback((country) => {
|
||||
const colors = {
|
||||
RU: '#dc2626',
|
||||
US: '#2563eb',
|
||||
DE: '#059669',
|
||||
SE: '#7c3aed',
|
||||
NL: '#ea580c',
|
||||
SG: '#0891b2',
|
||||
};
|
||||
return colors[country] || '#6b7280';
|
||||
}, []);
|
||||
|
||||
// Размеры карточки узла
|
||||
const NODE_WIDTH = 160;
|
||||
const NODE_HEIGHT = 74;
|
||||
const computeGridPosition = useCallback((index, total) => {
|
||||
const cols = Math.ceil(Math.sqrt(total));
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
const spacingX = 280;
|
||||
const spacingY = 180;
|
||||
return { x: 80 + col * spacingX, y: 80 + row * spacingY };
|
||||
}, []);
|
||||
|
||||
const initialNodesData = useMemo(() => {
|
||||
return (servers || []).map((s, i) => ({
|
||||
id: String(s.ip),
|
||||
type: 'server',
|
||||
position: computeGridPosition(i, servers.length),
|
||||
data: { server: s },
|
||||
}));
|
||||
}, [servers, computeGridPosition]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodesData);
|
||||
|
||||
// Автоматическая раскладка (force-directed)
|
||||
useEffect(() => {
|
||||
if (!servers || servers.length === 0) return;
|
||||
setNodes(initialNodesData);
|
||||
}, [initialNodesData, setNodes]);
|
||||
|
||||
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 };
|
||||
const initialEdgesData = useMemo(() => {
|
||||
return (connections || []).map((c, idx) => {
|
||||
const style = getTunnelStyle(c.tunnelType);
|
||||
return {
|
||||
id: `${c.from}-${c.to}-${idx}`,
|
||||
source: String(c.from),
|
||||
target: String(c.to),
|
||||
label: c.ipA && c.ipB ? `${c.tunnelType} ${c.ipA} ⇄ ${c.ipB}` : c.tunnelType,
|
||||
style: { stroke: style.color, strokeWidth: style.width, strokeDasharray: style.dash },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
|
||||
animated: false,
|
||||
};
|
||||
});
|
||||
setNodePositions(pos);
|
||||
}, [connections, getTunnelStyle]);
|
||||
|
||||
// После раскладки подгоняем граф по области видимости
|
||||
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
|
||||
}
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(initialEdgesData);
|
||||
}, [initialEdgesData, setEdges]);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge({ ...params, type: 'default' }, eds)), [setEdges]);
|
||||
|
||||
const nodeTypes = useMemo(
|
||||
() => ({
|
||||
server: ({ data }) => {
|
||||
const s = data.server || {};
|
||||
const countryColor = getCountryColor(s.country);
|
||||
return (
|
||||
<div
|
||||
className="card shadow-sm"
|
||||
style={{
|
||||
width: 200,
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${countryColor}`,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-1 d-flex align-items-center" style={{ background: '#f8fafc', borderBottom: '1px solid #eef2f7' }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
background: '#fff',
|
||||
color: '#475569',
|
||||
border: `1px solid ${countryColor}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{String(s.country || '').toUpperCase().slice(0, 3)}
|
||||
</span>
|
||||
<div className="ms-2 text-truncate" style={{ fontWeight: 700, color: '#1f2937', fontSize: 13 }}>{s.dns}</div>
|
||||
</div>
|
||||
<div className="px-2 py-2" style={{ lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 12, color: '#334155' }}>{s.ip}</div>
|
||||
<div style={{ fontSize: 10, color: '#6b7280' }}>{s.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[getCountryColor]
|
||||
);
|
||||
|
||||
const onInit = useCallback((inst) => {
|
||||
instanceRef.current = inst;
|
||||
// Автоподгон при инициализации
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
inst.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||
} catch {}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceRef.current) return;
|
||||
// Подгон при изменении данных
|
||||
const i = instanceRef.current;
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
i.fitView({ padding: 0.2, includeHiddenNodes: true, minZoom: 0.2, maxZoom: 2 });
|
||||
} catch {}
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [servers, connections]);
|
||||
|
||||
// Зум/панорамирование
|
||||
useEffect(() => {
|
||||
const svgEl = svgRef.current;
|
||||
const svg = d3.select(svgEl);
|
||||
const g = d3.select(gRef.current);
|
||||
const vb = svgEl?.viewBox?.baseVal;
|
||||
const width = (vb && vb.width) ? vb.width : (svgEl?.clientWidth || 900);
|
||||
const height = (vb && vb.height) ? vb.height : (svgEl?.clientHeight || 600);
|
||||
|
||||
const zoom = d3
|
||||
.zoom()
|
||||
.scaleExtent([ZOOM_MIN, ZOOM_MAX])
|
||||
.extent([[0, 0], [width, height]])
|
||||
.translateExtent([[-10000, -10000], [10000, 10000]])
|
||||
.on('zoom', (event) => {
|
||||
const t = event.transform;
|
||||
g.attr('transform', `translate(${t.x},${t.y}) scale(${t.k})`);
|
||||
setTransformState({ k: t.k, x: t.x, y: t.y });
|
||||
});
|
||||
zoomRef.current = zoom;
|
||||
svg
|
||||
.call(zoom)
|
||||
.on('dblclick.zoom', null);
|
||||
dbg('zoom initialized', { width, height });
|
||||
}, []);
|
||||
|
||||
// Горячая клавиша: пробел включает режим панорамирования (чтобы тянуть в любом месте, даже на узлах)
|
||||
useEffect(() => {
|
||||
const down = (e) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
setIsPanningMode(true);
|
||||
panModeRef.current = true;
|
||||
}
|
||||
};
|
||||
const up = (e) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
setIsPanningMode(false);
|
||||
panModeRef.current = false;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', down, { passive: false });
|
||||
window.addEventListener('keyup', up, { passive: false });
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down);
|
||||
window.removeEventListener('keyup', up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Контролы масштабирования
|
||||
const zoomBy = (factor) => {
|
||||
if (!zoomRef.current || !svgRef.current) return;
|
||||
const svgEl = svgRef.current;
|
||||
const vb = svgEl.viewBox?.baseVal;
|
||||
const cx = (vb && vb.width) ? vb.width / 2 : svgEl.clientWidth / 2;
|
||||
const cy = (vb && vb.height) ? vb.height / 2 : svgEl.clientHeight / 2;
|
||||
const current = d3.zoomTransform(svgEl);
|
||||
dbg('zoomBy click', { factor, current: { k: current.k, x: current.x, y: current.y }, center: { cx, cy } });
|
||||
const svgSel = d3.select(svgEl);
|
||||
svgSel.interrupt();
|
||||
svgSel
|
||||
.transition()
|
||||
.duration(220)
|
||||
.call(zoomRef.current.scaleBy, factor, [cx, cy])
|
||||
.on('end', () => {
|
||||
const nt = d3.zoomTransform(svgEl);
|
||||
dbg('zoomBy end', { next: { k: nt.k, x: nt.x, y: nt.y } });
|
||||
});
|
||||
};
|
||||
|
||||
const zoomTo = (scale) => {
|
||||
if (!zoomRef.current || !svgRef.current) return;
|
||||
const svgEl = svgRef.current;
|
||||
const vb = svgEl.viewBox?.baseVal;
|
||||
const cx = (vb && vb.width) ? vb.width / 2 : svgEl.clientWidth / 2;
|
||||
const cy = (vb && vb.height) ? vb.height / 2 : svgEl.clientHeight / 2;
|
||||
const nextScale = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, scale));
|
||||
const svgSel = d3.select(svgEl);
|
||||
svgSel.interrupt();
|
||||
svgSel
|
||||
.transition()
|
||||
.duration(200)
|
||||
.call(zoomRef.current.scaleTo, nextScale, [cx, cy]);
|
||||
};
|
||||
|
||||
const fitToView = () => {
|
||||
const svgEl = svgRef.current;
|
||||
if (!svgEl) return;
|
||||
const vb = svgEl.viewBox?.baseVal;
|
||||
const box = { width: (vb && vb.width) ? vb.width : svgEl.clientWidth, height: (vb && vb.height) ? vb.height : svgEl.clientHeight };
|
||||
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(ZOOM_MIN, Math.min(ZOOM_MAX, 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).interrupt().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?.();
|
||||
}
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
if (!zoomRef.current || !svgRef.current) return;
|
||||
const svgEl = svgRef.current;
|
||||
d3.select(svgEl)
|
||||
.transition()
|
||||
.duration(220)
|
||||
.call(zoomRef.current.transform, d3.zoomIdentity)
|
||||
.on('end', () => {
|
||||
const nt = d3.zoomTransform(svgEl);
|
||||
dbg('resetZoom end', { k: nt.k, x: nt.x, y: nt.y });
|
||||
});
|
||||
};
|
||||
|
||||
if (servers.length === 0) {
|
||||
if (!servers || servers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-5">
|
||||
<div className="text-muted">
|
||||
@@ -231,389 +159,33 @@ function GraphView({ servers, connections }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Инициализация позиций узлов при первом рендере
|
||||
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) => {
|
||||
// В режиме панорамирования — не начинаем перетаскивание узла, даём d3.zoom обработать drag
|
||||
if (panModeRef.current) 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());
|
||||
|
||||
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 (
|
||||
<div ref={containerRef} style={{ width: '100%', height: '500px', border: '1px solid #e5e7eb', borderRadius: '8px', overflow: 'hidden', background: '#f8fafc', position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: (draggedNode || isPanningMode) ? 'grabbing' : 'grab' }}
|
||||
<div style={{ width: '100%', height: 500, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<ReactFlow
|
||||
ref={flowRef}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
}}
|
||||
onInit={onInit}
|
||||
>
|
||||
<defs>
|
||||
{/* Лёгкая сетка фона */}
|
||||
<pattern id="grid" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#f2f4f6" strokeWidth="1" />
|
||||
</pattern>
|
||||
{/* Определение стрелок под цвет канала */}
|
||||
{Array.from(new Set(connections.map(c => c.tunnelType))).map((t) => {
|
||||
const style = getTunnelStyle(t);
|
||||
return (
|
||||
<marker key={`arrow-${t}`}
|
||||
id={`arrow-${t}`}
|
||||
markerWidth="12"
|
||||
markerHeight="8"
|
||||
refX="10"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 12 4, 0 8" fill={style.color} />
|
||||
</marker>
|
||||
);
|
||||
})}
|
||||
{/* Свечение для выделения */}
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Тень карточки */}
|
||||
<filter id="cardShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodOpacity="0.2" />
|
||||
</filter>
|
||||
{/* Тень для ярлыков ссылок */}
|
||||
<filter id="labelShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.25" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g ref={gRef}>
|
||||
{/* Подложка сетки (внутри слоя зума) */}
|
||||
<rect x={-10000} y={-10000} width={20000} height={20000} fill="url(#grid)" />
|
||||
{/* Связи */}
|
||||
{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 (
|
||||
<g key={`link-${index}`}
|
||||
onMouseEnter={() => setHoveredLinkId(id)}
|
||||
onMouseLeave={() => setHoveredLinkId(null)}
|
||||
>
|
||||
{/* Линия связи */}
|
||||
<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}
|
||||
markerEnd={`url(#arrow-${connection.tunnelType})`}
|
||||
filter={isHovered ? 'url(#glow)' : undefined}
|
||||
opacity={isHovered ? 1 : 0.9}
|
||||
/>
|
||||
|
||||
{/* Подпись связи: ярлык с подложкой и halo */}
|
||||
{(() => {
|
||||
const labelW = 140;
|
||||
const labelH = 38;
|
||||
const lx = cx - labelW / 2;
|
||||
const ly = cy - labelH / 2 - 4; // слегка выше вершины
|
||||
return (
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
<rect
|
||||
x={lx}
|
||||
y={ly}
|
||||
width={labelW}
|
||||
height={labelH}
|
||||
rx="16"
|
||||
fill="#ffffff"
|
||||
stroke={style.color}
|
||||
strokeWidth="1"
|
||||
filter="url(#labelShadow)"
|
||||
/>
|
||||
{/* Halo для заголовка */}
|
||||
<text
|
||||
x={cx}
|
||||
y={ly + 16}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="3"
|
||||
strokeLinejoin="round"
|
||||
fill="#374151"
|
||||
style={{ paintOrder: 'stroke fill' }}
|
||||
>
|
||||
{connection.tunnelType}
|
||||
</text>
|
||||
{/* Линия IP с более мелким шрифтом */}
|
||||
<text
|
||||
x={cx}
|
||||
y={ly + 30}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#6b7280"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
style={{ paintOrder: 'stroke fill' }}
|
||||
>
|
||||
{connection.ipA} ⇄ {connection.ipB}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Узлы (серверы) */}
|
||||
{servers.map((server) => {
|
||||
const pos = getNodePosition(server.ip);
|
||||
const countryColor = getCountryColor(server.country);
|
||||
const isDragging = draggedNode === server.ip;
|
||||
|
||||
return (
|
||||
<g key={server.ip}>
|
||||
{/* Карточка узла */}
|
||||
<rect
|
||||
x={pos.x - NODE_WIDTH / 2}
|
||||
y={pos.y - NODE_HEIGHT / 2}
|
||||
rx={12}
|
||||
ry={12}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
fill="#ffffff"
|
||||
stroke={countryColor}
|
||||
strokeWidth={2}
|
||||
filter="url(#cardShadow)"
|
||||
style={{ cursor: 'grab' }}
|
||||
onMouseDown={(e) => { e.stopPropagation(); 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 (
|
||||
<g>
|
||||
<rect
|
||||
x={badgeX}
|
||||
y={badgeY}
|
||||
rx={8}
|
||||
ry={8}
|
||||
width={52}
|
||||
height={20}
|
||||
fill="#fff"
|
||||
stroke={countryColor}
|
||||
/>
|
||||
{/* Рендер флага через foreignObject как в HTML (надёжнее для Windows/Chrome) */}
|
||||
<foreignObject x={badgeX + 4} y={badgeY + 3} width={16} height={14} style={{ pointerEvents: 'none' }}>
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"
|
||||
style={{fontSize:'14px', lineHeight:'14px', fontFamily:'Segoe UI Emoji, Noto Color Emoji, Apple Color Emoji, "Twemoji Mozilla", "EmojiOne Color", sans-serif'}}>
|
||||
{flag}
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text
|
||||
x={badgeX + 30}
|
||||
y={badgeY + 13}
|
||||
fontSize="11"
|
||||
fill="#475569"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{String(server.country).toUpperCase().slice(0,3)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
{/* Название */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fill="#1f2937"
|
||||
fontWeight="700"
|
||||
>
|
||||
{server.dns}
|
||||
</text>
|
||||
{/* IP */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fill="#334155"
|
||||
>
|
||||
{server.ip}
|
||||
</text>
|
||||
{/* Провайдер */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 30}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#6b7280"
|
||||
>
|
||||
{server.provider}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Доп. определения добавлены выше */}
|
||||
</g>
|
||||
</svg>
|
||||
{/* Контролы масштабирования + ползунок */}
|
||||
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto', width: 220 }}>
|
||||
<div className="btn-group btn-group-sm" style={{ width: '100%' }}>
|
||||
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1/1.2)} title="Уменьшить">
|
||||
<IconZoomOut size={16} />
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
|
||||
<IconZoomIn size={16} />
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={fitToView} title="Подогнать к окну">
|
||||
Fit
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={resetZoom} title="Сброс масштаба">
|
||||
100%
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={toggleFullscreen} title={isFullscreen ? 'Выйти из полноэкранного' : 'Во весь экран'}>
|
||||
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 d-flex align-items-center" style={{ gap: 8 }}>
|
||||
<span className="text-muted" style={{ fontSize: 12, width: 34 }}>{Math.round(transformState.k * 100)}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={transformState.k}
|
||||
onChange={(e) => zoomTo(parseFloat(e.target.value))}
|
||||
className="form-range"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Background variant="dots" gap={20} size={1} color="#e5e7eb" />
|
||||
<MiniMap
|
||||
nodeColor={(n) => getCountryColor(n.data?.server?.country)}
|
||||
nodeStrokeWidth={2}
|
||||
maskColor="rgba(0,0,0,0.05)"
|
||||
/>
|
||||
<Controls position="top-right" showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GraphView;
|
||||
export default GraphView;
|
||||
Reference in New Issue
Block a user