feat: Enhance GraphView with node dimensions, background grid, and improved layout adjustments for better visualization and interactivity
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m37s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m37s
This commit is contained in:
+85
-83
@@ -8,6 +8,11 @@ function GraphView({ servers, connections }) {
|
|||||||
const [hoveredLinkId, setHoveredLinkId] = useState(null);
|
const [hoveredLinkId, setHoveredLinkId] = useState(null);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const gRef = useRef(null);
|
const gRef = useRef(null);
|
||||||
|
const zoomRef = useRef(null);
|
||||||
|
|
||||||
|
// Размеры карточки узла
|
||||||
|
const NODE_WIDTH = 160;
|
||||||
|
const NODE_HEIGHT = 74;
|
||||||
|
|
||||||
// Автоматическая раскладка (force-directed)
|
// Автоматическая раскладка (force-directed)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,20 +38,42 @@ function GraphView({ servers, connections }) {
|
|||||||
pos[n.id] = { x: n.x, y: n.y };
|
pos[n.id] = { x: n.x, y: n.y };
|
||||||
});
|
});
|
||||||
setNodePositions(pos);
|
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]);
|
}, [servers, connections]);
|
||||||
|
|
||||||
// Зум/панорамирование
|
// Зум/панорамирование
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const svg = d3.select(svgRef.current);
|
const svg = d3.select(svgRef.current);
|
||||||
const g = d3.select(gRef.current);
|
const g = d3.select(gRef.current);
|
||||||
svg.call(
|
zoomRef.current = d3
|
||||||
d3
|
.zoom()
|
||||||
.zoom()
|
|
||||||
.scaleExtent([0.5, 2])
|
.scaleExtent([0.5, 2])
|
||||||
.on('zoom', (event) => {
|
.on('zoom', (event) => {
|
||||||
g.attr('transform', event.transform);
|
g.attr('transform', event.transform);
|
||||||
})
|
});
|
||||||
);
|
svg.call(zoomRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
@@ -164,6 +191,10 @@ function GraphView({ servers, connections }) {
|
|||||||
style={{ cursor: draggedNode ? 'grabbing' : 'default' }}
|
style={{ cursor: draggedNode ? 'grabbing' : 'default' }}
|
||||||
>
|
>
|
||||||
<defs>
|
<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) => {
|
{Array.from(new Set(connections.map(c => c.tunnelType))).map((t) => {
|
||||||
const style = getTunnelStyle(t);
|
const style = getTunnelStyle(t);
|
||||||
@@ -188,7 +219,13 @@ function GraphView({ servers, connections }) {
|
|||||||
<feMergeNode in="SourceGraphic"/>
|
<feMergeNode in="SourceGraphic"/>
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
|
{/* Тень карточки */}
|
||||||
|
<filter id="cardShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" floodOpacity="0.2" />
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
{/* Подложка сетки */}
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
<g ref={gRef}>
|
<g ref={gRef}>
|
||||||
{/* Связи */}
|
{/* Связи */}
|
||||||
{connections.map((connection, index) => {
|
{connections.map((connection, index) => {
|
||||||
@@ -229,6 +266,7 @@ function GraphView({ servers, connections }) {
|
|||||||
markerEnd={`url(#arrow-${connection.tunnelType})`}
|
markerEnd={`url(#arrow-${connection.tunnelType})`}
|
||||||
filter={isHovered ? 'url(#glow)' : undefined}
|
filter={isHovered ? 'url(#glow)' : undefined}
|
||||||
opacity={isHovered ? 1 : 0.9}
|
opacity={isHovered ? 1 : 0.9}
|
||||||
|
style={{ vectorEffect: 'non-scaling-stroke' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Подпись связи */}
|
{/* Подпись связи */}
|
||||||
@@ -242,7 +280,7 @@ function GraphView({ servers, connections }) {
|
|||||||
stroke={style.color}
|
stroke={style.color}
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={mx - 10}
|
x={mx - 10}
|
||||||
y={my - 18}
|
y={my - 18}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
@@ -273,107 +311,71 @@ function GraphView({ servers, connections }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={server.ip}>
|
<g key={server.ip}>
|
||||||
{/* Тень */}
|
{/* Карточка узла */}
|
||||||
<circle
|
<rect
|
||||||
cx={pos.x + 2}
|
x={pos.x - NODE_WIDTH / 2}
|
||||||
cy={pos.y + 2}
|
y={pos.y - NODE_HEIGHT / 2}
|
||||||
r="45"
|
rx={12}
|
||||||
fill="rgba(0,0,0,0.1)"
|
ry={12}
|
||||||
style={{ pointerEvents: 'none' }}
|
width={NODE_WIDTH}
|
||||||
/>
|
height={NODE_HEIGHT}
|
||||||
|
fill="#ffffff"
|
||||||
{/* Основной круг */}
|
|
||||||
<circle
|
|
||||||
cx={pos.x}
|
|
||||||
cy={pos.y}
|
|
||||||
r="45"
|
|
||||||
fill={isDragging ? "#f1f5f9" : "white"}
|
|
||||||
stroke={countryColor}
|
stroke={countryColor}
|
||||||
strokeWidth="3"
|
strokeWidth={2}
|
||||||
|
filter="url(#cardShadow)"
|
||||||
style={{ cursor: 'grab' }}
|
style={{ cursor: 'grab' }}
|
||||||
onMouseDown={(e) => handleMouseDown(e, server.ip)}
|
onMouseDown={(e) => handleMouseDown(e, server.ip)}
|
||||||
/>
|
/>
|
||||||
|
{/* Плашка страны */}
|
||||||
{/* Внутренний круг с цветом страны */}
|
<rect
|
||||||
<circle
|
x={pos.x - NODE_WIDTH / 2 + 8}
|
||||||
cx={pos.x}
|
y={pos.y - NODE_HEIGHT / 2 - 10}
|
||||||
cy={pos.y}
|
rx={8}
|
||||||
r="35"
|
ry={8}
|
||||||
|
width={28}
|
||||||
|
height={18}
|
||||||
fill={countryColor}
|
fill={countryColor}
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Иконка сервера */}
|
|
||||||
<text
|
<text
|
||||||
x={pos.x}
|
x={pos.x - NODE_WIDTH / 2 + 22}
|
||||||
y={pos.y - 8}
|
y={pos.y - NODE_HEIGHT / 2 + 3}
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="16"
|
|
||||||
fill="white"
|
|
||||||
fontWeight="bold"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
🖥️
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* DNS имя */}
|
|
||||||
<text
|
|
||||||
x={pos.x}
|
|
||||||
y={pos.y + 15}
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="10"
|
fontSize="10"
|
||||||
fill="white"
|
fill="#fff"
|
||||||
fontWeight="bold"
|
fontWeight="700"
|
||||||
style={{ pointerEvents: 'none' }}
|
>
|
||||||
|
{server.country}
|
||||||
|
</text>
|
||||||
|
{/* Название */}
|
||||||
|
<text
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="13"
|
||||||
|
fill="#1f2937"
|
||||||
|
fontWeight="700"
|
||||||
>
|
>
|
||||||
{server.dns}
|
{server.dns}
|
||||||
</text>
|
</text>
|
||||||
|
{/* IP */}
|
||||||
{/* IP адрес */}
|
|
||||||
<text
|
<text
|
||||||
x={pos.x}
|
x={pos.x}
|
||||||
y={pos.y + 28}
|
y={pos.y + 14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="9"
|
fontSize="12"
|
||||||
fill="white"
|
fill="#334155"
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
>
|
||||||
{server.ip}
|
{server.ip}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Провайдер */}
|
{/* Провайдер */}
|
||||||
<text
|
<text
|
||||||
x={pos.x}
|
x={pos.x}
|
||||||
y={pos.y + 45}
|
y={pos.y + 30}
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="8"
|
|
||||||
fill="#6b7280"
|
|
||||||
fontWeight="bold"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
{server.provider}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Бейдж страны */}
|
|
||||||
<rect
|
|
||||||
x={pos.x - 20}
|
|
||||||
y={pos.y - 55}
|
|
||||||
width="40"
|
|
||||||
height="20"
|
|
||||||
rx="10"
|
|
||||||
fill={countryColor}
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={pos.x}
|
|
||||||
y={pos.y - 42}
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="10"
|
fontSize="10"
|
||||||
fill="white"
|
fill="#6b7280"
|
||||||
fontWeight="bold"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
>
|
||||||
{server.country}
|
{server.provider}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user