feat: Add zoom controls and country flag display in GraphView for enhanced interactivity and visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m52s

This commit is contained in:
2025-08-08 15:48:31 +07:00
parent 5bdb4cfdd9
commit 896f2e668c
+84 -21
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import * as d3 from 'd3';
import { IconZoomIn, IconZoomOut, IconMaximize } from '@tabler/icons-react';
function GraphView({ servers, connections }) {
const [nodePositions, setNodePositions] = useState({});
@@ -69,13 +70,44 @@ function GraphView({ servers, connections }) {
const g = d3.select(gRef.current);
zoomRef.current = d3
.zoom()
.scaleExtent([0.5, 2])
.scaleExtent([0.3, 3])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoomRef.current);
}, []);
// Контролы масштабирования
const zoomBy = (factor) => {
const svg = d3.select(svgRef.current);
svg.transition().duration(200).call(zoomRef.current.scaleBy, factor);
};
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);
};
if (servers.length === 0) {
return (
<div className="text-center py-5">
@@ -178,8 +210,16 @@ function GraphView({ servers, connections }) {
return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 };
};
// Флаг страны по ISO-2 (emoji). Для не-ISO-2 вернём код как есть
const getFlagEmoji = (countryCode) => {
if (!countryCode) return '';
const cc = String(countryCode).trim().toUpperCase();
if (cc.length !== 2) return cc; // например, SWE
return cc.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt()));
};
return (
<div style={{ width: '100%', height: '500px', border: '1px solid #e5e7eb', borderRadius: '8px', overflow: 'hidden', background: '#f8fafc' }}>
<div style={{ width: '100%', height: '500px', border: '1px solid #e5e7eb', borderRadius: '8px', overflow: 'hidden', background: '#f8fafc', position: 'relative' }}>
<svg
ref={svgRef}
width="100%"
@@ -327,25 +367,34 @@ function GraphView({ servers, connections }) {
onMouseDown={(e) => handleMouseDown(e, server.ip)}
/>
{/* Плашка страны */}
<rect
x={pos.x - NODE_WIDTH / 2 + 8}
y={pos.y - NODE_HEIGHT / 2 - 10}
rx={8}
ry={8}
width={28}
height={18}
fill={countryColor}
/>
<text
x={pos.x - NODE_WIDTH / 2 + 22}
y={pos.y - NODE_HEIGHT / 2 + 3}
textAnchor="middle"
fontSize="10"
fill="#fff"
fontWeight="700"
>
{server.country}
</text>
{(() => {
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={36}
height={18}
fill="#fff"
stroke={countryColor}
/>
<text
x={badgeX + 18}
y={badgeY + 12}
textAnchor="middle"
fontSize="12"
dominantBaseline="middle"
>
{flag}
</text>
</g>
);
})()}
{/* Название */}
<text
x={pos.x}
@@ -384,6 +433,20 @@ function GraphView({ servers, connections }) {
{/* Доп. определения добавлены выше */}
</g>
</svg>
{/* Контролы масштабирования */}
<div className="position-absolute" style={{ right: 10, top: 10 }}>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
<IconZoomIn size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1/1.2)} title="Уменьшить">
<IconZoomOut size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={fitToView} title="Подогнать по окну">
<IconMaximize size={16} />
</button>
</div>
</div>
</div>
);
}