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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user