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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m52s
This commit is contained in:
+84
-21
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user