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 gRef = useRef(null);
|
||||||
const zoomRef = useRef(null);
|
const zoomRef = useRef(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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;
|
const NODE_WIDTH = 160;
|
||||||
@@ -72,43 +77,27 @@ function GraphView({ servers, connections }) {
|
|||||||
|
|
||||||
// Зум/панорамирование
|
// Зум/панорамирование
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const svg = d3.select(svgRef.current);
|
const svgEl = svgRef.current;
|
||||||
|
const svg = d3.select(svgEl);
|
||||||
const g = d3.select(gRef.current);
|
const g = d3.select(gRef.current);
|
||||||
const vb = svgRef.current?.viewBox?.baseVal;
|
const vb = svgEl?.viewBox?.baseVal;
|
||||||
const width = (vb && vb.width) ? vb.width : (svgRef.current?.clientWidth || 900);
|
const width = (vb && vb.width) ? vb.width : (svgEl?.clientWidth || 900);
|
||||||
const height = (vb && vb.height) ? vb.height : (svgRef.current?.clientHeight || 600);
|
const height = (vb && vb.height) ? vb.height : (svgEl?.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 });
|
|
||||||
|
|
||||||
// Колёсико мыши — обычный масштаб (желательно для UX)
|
const zoom = d3
|
||||||
svg.on('wheel.zoom', null); // убираем двойное навешивание
|
.zoom()
|
||||||
svg.on('wheel', (event) => {
|
.scaleExtent([ZOOM_MIN, ZOOM_MAX])
|
||||||
event.preventDefault();
|
.extent([[0, 0], [width, height]])
|
||||||
const delta = Math.sign(event.deltaY);
|
.translateExtent([[-10000, -10000], [10000, 10000]])
|
||||||
const factor = delta > 0 ? 1 / 1.2 : 1.2;
|
.on('zoom', (event) => {
|
||||||
const svgEl = svgRef.current;
|
g.attr('transform', event.transform);
|
||||||
const center = d3.pointer(event, svgEl);
|
setTransformState({ k: event.transform.k, x: event.transform.x, y: event.transform.y });
|
||||||
const svgSel = d3.select(svgEl);
|
});
|
||||||
svgSel.interrupt();
|
zoomRef.current = zoom;
|
||||||
svgSel
|
svg
|
||||||
.transition()
|
.call(zoom)
|
||||||
.duration(220)
|
.on('dblclick.zoom', null);
|
||||||
.call(zoomRef.current.scaleBy, factor, center)
|
dbg('zoom initialized', { width, height });
|
||||||
.on('end', () => {
|
|
||||||
const nt = d3.zoomTransform(svgEl);
|
|
||||||
dbg('zoomBy end', { next: { k: nt.k, x: nt.x, y: nt.y } });
|
|
||||||
});
|
|
||||||
}, { passive: false });
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Контролы масштабирования
|
// Контролы масштабирования
|
||||||
@@ -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 fitToView = () => {
|
||||||
const svgEl = svgRef.current;
|
const svgEl = svgRef.current;
|
||||||
if (!svgEl) return;
|
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 padding = 40;
|
||||||
const positions = nodePositions;
|
const positions = nodePositions;
|
||||||
const ids = Object.keys(positions);
|
const ids = Object.keys(positions);
|
||||||
@@ -150,11 +155,11 @@ function GraphView({ servers, connections }) {
|
|||||||
});
|
});
|
||||||
const contentW = Math.max(1, maxX - minX);
|
const contentW = Math.max(1, maxX - minX);
|
||||||
const contentH = Math.max(1, maxY - minY);
|
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 tx = (box.width - scale * (minX + maxX)) / 2;
|
||||||
const ty = (box.height - scale * (minY + maxY)) / 2;
|
const ty = (box.height - scale * (minY + maxY)) / 2;
|
||||||
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
|
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}
|
strokeWidth={2}
|
||||||
filter="url(#cardShadow)"
|
filter="url(#cardShadow)"
|
||||||
style={{ cursor: 'grab' }}
|
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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
{/* Контролы масштабирования */}
|
{/* Контролы масштабирования + ползунок */}
|
||||||
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto' }}>
|
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 1000, pointerEvents: 'auto', width: 220 }}>
|
||||||
<div className="btn-group btn-group-sm">
|
<div className="btn-group btn-group-sm" style={{ width: '100%' }}>
|
||||||
<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="Уменьшить">
|
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1/1.2)} title="Уменьшить">
|
||||||
<IconZoomOut size={16} />
|
<IconZoomOut size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline-secondary" onClick={toggleFullscreen} title={isFullscreen ? 'Выйти из полноэкранного' : 'Во весь экран'}>
|
<button className="btn btn-outline-secondary" onClick={() => zoomBy(1.2)} title="Увеличить">
|
||||||
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
<IconZoomIn size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary" onClick={fitToView} title="Подогнать к окну">
|
||||||
|
Fit
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline-secondary" onClick={resetZoom} title="Сброс масштаба">
|
<button className="btn btn-outline-secondary" onClick={resetZoom} title="Сброс масштаба">
|
||||||
100%
|
100%
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user