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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m44s
This commit is contained in:
+68
-47
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user