feat: Introduce zoom slider and enhance zoom controls in GraphView for improved user interaction and accessibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m44s

This commit is contained in:
2025-08-08 18:08:29 +07:00
parent d36db114cd
commit b1b043f7bc
+68 -47
View File
@@ -16,6 +16,11 @@ function GraphView({ servers, connections }) {
const gRef = useRef(null);
const zoomRef = useRef(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [transformState, setTransformState] = useState({ k: 1, x: 0, y: 0 });
const ZOOM_MIN = 0.2;
const ZOOM_MAX = 4;
const ZOOM_STEP = 0.01;
// Размеры карточки узла
const NODE_WIDTH = 160;
@@ -72,43 +77,27 @@ function GraphView({ servers, connections }) {
// Зум/панорамирование
useEffect(() => {
const svg = d3.select(svgRef.current);
const svgEl = svgRef.current;
const svg = d3.select(svgEl);
const g = d3.select(gRef.current);
const vb = svgRef.current?.viewBox?.baseVal;
const width = (vb && vb.width) ? vb.width : (svgRef.current?.clientWidth || 900);
const height = (vb && vb.height) ? vb.height : (svgRef.current?.clientHeight || 600);
zoomRef.current = d3
.zoom()
.scaleExtent([0.3, 3])
.extent([[0, 0], [width, height]])
// Разрешаем панораму в широких пределах
.translateExtent([[-10000, -10000], [10000, 10000]])
.on('zoom', (event) => {
g.attr('transform', event.transform);
// dbg('onZoom', { k: event.transform.k, x: event.transform.x, y: event.transform.y });
});
svg.call(zoomRef.current);
dbg('zoom initialized', { width, height });
const vb = svgEl?.viewBox?.baseVal;
const width = (vb && vb.width) ? vb.width : (svgEl?.clientWidth || 900);
const height = (vb && vb.height) ? vb.height : (svgEl?.clientHeight || 600);
// Колёсико мыши — обычный масштаб (желательно для UX)
svg.on('wheel.zoom', null); // убираем двойное навешивание
svg.on('wheel', (event) => {
event.preventDefault();
const delta = Math.sign(event.deltaY);
const factor = delta > 0 ? 1 / 1.2 : 1.2;
const svgEl = svgRef.current;
const center = d3.pointer(event, svgEl);
const svgSel = d3.select(svgEl);
svgSel.interrupt();
svgSel
.transition()
.duration(220)
.call(zoomRef.current.scaleBy, factor, center)
.on('end', () => {
const nt = d3.zoomTransform(svgEl);
dbg('zoomBy end', { next: { k: nt.k, x: nt.x, y: nt.y } });
});
}, { passive: false });
const zoom = d3
.zoom()
.scaleExtent([ZOOM_MIN, ZOOM_MAX])
.extent([[0, 0], [width, height]])
.translateExtent([[-10000, -10000], [10000, 10000]])
.on('zoom', (event) => {
g.attr('transform', event.transform);
setTransformState({ k: event.transform.k, x: event.transform.x, y: event.transform.y });
});
zoomRef.current = zoom;
svg
.call(zoom)
.on('dblclick.zoom', null);
dbg('zoom initialized', { width, height });
}, []);
// Контролы масштабирования
@@ -132,10 +121,26 @@ function GraphView({ servers, connections }) {
});
};
const zoomTo = (scale) => {
if (!zoomRef.current || !svgRef.current) return;
const svgEl = svgRef.current;
const vb = svgEl.viewBox?.baseVal;
const cx = (vb && vb.width) ? vb.width / 2 : svgEl.clientWidth / 2;
const cy = (vb && vb.height) ? vb.height / 2 : svgEl.clientHeight / 2;
const nextScale = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, scale));
const svgSel = d3.select(svgEl);
svgSel.interrupt();
svgSel
.transition()
.duration(200)
.call(zoomRef.current.scaleTo, nextScale, [cx, cy]);
};
const fitToView = () => {
const svgEl = svgRef.current;
if (!svgEl) return;
const box = svgEl.getBoundingClientRect();
const vb = svgEl.viewBox?.baseVal;
const box = { width: (vb && vb.width) ? vb.width : svgEl.clientWidth, height: (vb && vb.height) ? vb.height : svgEl.clientHeight };
const padding = 40;
const positions = nodePositions;
const ids = Object.keys(positions);
@@ -150,11 +155,11 @@ function GraphView({ servers, connections }) {
});
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 scale = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, 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);
d3.select(svgEl).interrupt().transition().duration(250).call(zoomRef.current.transform, t);
};
// Полноэкранный режим
@@ -471,7 +476,7 @@ function GraphView({ servers, connections }) {
strokeWidth={2}
filter="url(#cardShadow)"
style={{ cursor: 'grab' }}
onMouseDown={(e) => handleMouseDown(e, server.ip)}
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, server.ip); }}
/>
{/* Плашка страны */}
{(() => {
@@ -547,21 +552,37 @@ function GraphView({ servers, connections }) {
{/* Доп. определения добавлены выше */}
</g>
</svg>
{/* Контролы масштабирования */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto' }}>
<div className="btn-group btn-group-sm">
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
<IconZoomIn size={16} />
</button>
{/* Контролы масштабирования + ползунок */}
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto', width: 220 }}>
<div className="btn-group btn-group-sm" style={{ width: '100%' }}>
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1/1.2)} title="Уменьшить">
<IconZoomOut size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={toggleFullscreen} title={isFullscreen ? 'Выйти из полноэкранного' : 'Во весь экран'}>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
<IconZoomIn size={16} />
</button>
<button className="btn btn-outline-secondary" onClick={fitToView} title="Подогнать к окну">
Fit
</button>
<button className="btn btn-outline-secondary" onClick={resetZoom} title="Сброс масштаба">
100%
</button>
<button className="btn btn-outline-secondary" onClick={toggleFullscreen} title={isFullscreen ? 'Выйти из полноэкранного' : 'Во весь экран'}>
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</div>
<div className="mt-2 d-flex align-items-center" style={{ gap: 8 }}>
<span className="text-muted" style={{ fontSize: 12, width: 34 }}>{Math.round(transformState.k * 100)}%</span>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={transformState.k}
onChange={(e) => zoomTo(parseFloat(e.target.value))}
className="form-range"
style={{ width: '100%' }}
/>
</div>
</div>
</div>