diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx index f7170c1..2a8700f 100644 --- a/frontend/src/GraphView.jsx +++ b/frontend/src/GraphView.jsx @@ -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 }) { {/* Доп. определения добавлены выше */} - {/* Контролы масштабирования */} -
-
- + {/* Контролы масштабирования + ползунок */} +
+
- + + +
+
+ {Math.round(transformState.k * 100)}% + zoomTo(parseFloat(e.target.value))} + className="form-range" + style={{ width: '100%' }} + />