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 React, { useState, useRef, useEffect } from 'react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { IconZoomIn, IconZoomOut, IconMaximize } from '@tabler/icons-react';
function GraphView({ servers, connections }) { function GraphView({ servers, connections }) {
const [nodePositions, setNodePositions] = useState({}); const [nodePositions, setNodePositions] = useState({});
@@ -69,13 +70,44 @@ function GraphView({ servers, connections }) {
const g = d3.select(gRef.current); const g = d3.select(gRef.current);
zoomRef.current = d3 zoomRef.current = d3
.zoom() .zoom()
.scaleExtent([0.5, 2]) .scaleExtent([0.3, 3])
.on('zoom', (event) => { .on('zoom', (event) => {
g.attr('transform', event.transform); g.attr('transform', event.transform);
}); });
svg.call(zoomRef.current); 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) { if (servers.length === 0) {
return ( return (
<div className="text-center py-5"> <div className="text-center py-5">
@@ -178,8 +210,16 @@ function GraphView({ servers, connections }) {
return map[tunnelType] || { color: '#667382', dash: '5,5', width: 2 }; 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 ( 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 <svg
ref={svgRef} ref={svgRef}
width="100%" width="100%"
@@ -327,25 +367,34 @@ function GraphView({ servers, connections }) {
onMouseDown={(e) => handleMouseDown(e, server.ip)} onMouseDown={(e) => handleMouseDown(e, server.ip)}
/> />
{/* Плашка страны */} {/* Плашка страны */}
<rect {(() => {
x={pos.x - NODE_WIDTH / 2 + 8} const badgeX = pos.x - NODE_WIDTH / 2 + 8;
y={pos.y - NODE_HEIGHT / 2 - 10} const badgeY = pos.y - NODE_HEIGHT / 2 - 10;
rx={8} const flag = getFlagEmoji(server.country);
ry={8} return (
width={28} <g>
height={18} <rect
fill={countryColor} x={badgeX}
/> y={badgeY}
<text rx={8}
x={pos.x - NODE_WIDTH / 2 + 22} ry={8}
y={pos.y - NODE_HEIGHT / 2 + 3} width={36}
textAnchor="middle" height={18}
fontSize="10" fill="#fff"
fill="#fff" stroke={countryColor}
fontWeight="700" />
> <text
{server.country} x={badgeX + 18}
</text> y={badgeY + 12}
textAnchor="middle"
fontSize="12"
dominantBaseline="middle"
>
{flag}
</text>
</g>
);
})()}
{/* Название */} {/* Название */}
<text <text
x={pos.x} x={pos.x}
@@ -384,6 +433,20 @@ function GraphView({ servers, connections }) {
{/* Доп. определения добавлены выше */} {/* Доп. определения добавлены выше */}
</g> </g>
</svg> </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> </div>
); );
} }