diff --git a/frontend/src/components/wds_topology/ZoomControls.tsx b/frontend/src/components/wds_topology/ZoomControls.tsx index bcd5d52bd..08f7011b8 100644 --- a/frontend/src/components/wds_topology/ZoomControls.tsx +++ b/frontend/src/components/wds_topology/ZoomControls.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useEffect, useCallback, useMemo } from 'react'; +import { memo, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, Button, @@ -19,8 +19,8 @@ import { ViewQuilt, Fullscreen, FullscreenExit, - ChevronLeft, - ChevronRight, + ExpandLess, + ExpandMore, } from '@mui/icons-material'; import { useReactFlow } from 'reactflow'; import { useTranslation } from 'react-i18next'; @@ -28,11 +28,6 @@ import useZoomStore, { zoomPresets } from '../../stores/zoomStore'; import useEdgeTypeStore from '../../stores/edgeTypeStore'; // Optimized animations with GPU acceleration -const pulseGlow = keyframes` - 0%, 100% { transform: scale3d(1, 1, 1); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); } - 50% { transform: scale3d(1, 1, 1); box-shadow: 0 0 0 4px rgba(59, 130, 246, 0); } -`; - const bounceIn = keyframes` 0% { transform: scale3d(0.3, 0.3, 1); opacity: 0; } 50% { transform: scale3d(1.05, 1.05, 1); } @@ -40,12 +35,6 @@ const bounceIn = keyframes` 100% { transform: scale3d(1, 1, 1); opacity: 1; } `; -const wiggle = keyframes` - 0%, 100% { transform: rotate(0deg); } - 25% { transform: rotate(1deg); } - 75% { transform: rotate(-1deg); } -`; - interface ZoomControlsProps { theme: string; onToggleCollapse: () => void; @@ -69,14 +58,74 @@ export const ZoomControls = memo( translationPrefix = 'wecsTopology', }) => { const { t } = useTranslation(); - const { getZoom, setViewport, getViewport } = useReactFlow(); + const rf = useReactFlow(); + const { getZoom, setViewport, getViewport } = rf; const [zoomLevel, setZoomLevel] = useState(120); const [presetMenuAnchor, setPresetMenuAnchor] = useState(null); const [hoveredButton, setHoveredButton] = useState(null); const [showControls, setShowControls] = useState(true); + const panelRef = useRef(null); + const storageKey = useMemo( + () => `zoomControlsVisibility-${translationPrefix}`, + [translationPrefix] + ); + const [containerHeight, setContainerHeight] = useState(800); + + useEffect(() => { + const element = panelRef.current?.parentElement ?? panelRef.current; + if (!element) return; + + const height = element.clientHeight || 800; + setContainerHeight(height); + }, []); + + const scaleFactor = useMemo(() => { + const baseHeight = 700; + const minScale = 0.85; + const maxScale = 1.5; + const calculatedScale = Math.max(minScale, Math.min(maxScale, containerHeight / baseHeight)); + return calculatedScale; + }, [containerHeight]); + + const controlSizes = useMemo( + () => ({ + buttonSize: Math.round(36 * scaleFactor), + panelGap: Math.max(0.3, Math.min(1.2, 0.7 * scaleFactor)), + panelPadding: `${Math.round(6 * scaleFactor)}px ${Math.round(6 * scaleFactor)}px ${Math.round(6 * scaleFactor)}px`, + separatorWidth: `${Math.round(26 * scaleFactor)}px`, + typographyPadding: `${Math.round(5 * scaleFactor)}px ${Math.round(8 * scaleFactor)}px`, + typographyFontSize: `clamp(9px, ${10 * scaleFactor}px, 14px)`, + toggleHeight: Math.round(34 * scaleFactor), + iconFontSize: (scaleFactor < 0.85 ? 'small' : scaleFactor > 1.2 ? 'medium' : 'small') as + | 'inherit' + | 'large' + | 'medium' + | 'small', + }), + [scaleFactor] + ); + + useEffect(() => { + try { + const storedValue = localStorage.getItem(storageKey); + if (storedValue !== null) { + setShowControls(storedValue === 'expanded'); + } + } catch (error) { + console.warn('Unable to read zoom control state:', error); + } + }, [storageKey]); const toggleControls = () => { - setShowControls(prev => !prev); + setShowControls(prev => { + const nextState = !prev; + try { + localStorage.setItem(storageKey, nextState ? 'expanded' : 'collapsed'); + } catch (error) { + console.warn('Unable to persist zoom control state:', error); + } + return nextState; + }); }; const { setZoom } = useZoomStore(); const { edgeType, setEdgeType } = useEdgeTypeStore(); @@ -87,48 +136,79 @@ export const ZoomControls = memo( }, []); useEffect(() => { - const updateZoomLevel = () => { - const currentZoom = getZoom() * 100; - const snappedZoom = snapToStep(currentZoom); - setZoomLevel(Math.min(Math.max(snappedZoom, 10), 200)); - setZoom(getZoom()); + const rfInstance = rf as typeof rf & { + on?: ( + event: string, + handler: (data: { viewport: { zoom: number; x: number; y: number } }) => void + ) => void; + off?: ( + event: string, + handler: (data: { viewport: { zoom: number; x: number; y: number } }) => void + ) => void; }; - updateZoomLevel(); + if ( + rfInstance && + typeof rfInstance.on === 'function' && + typeof rfInstance.off === 'function' + ) { + const handleMove = ({ viewport }: { viewport: { zoom: number; x: number; y: number } }) => { + const snapped = snapToStep(viewport.zoom * 100); + setZoomLevel(Math.min(Math.max(snapped, 10), 200)); + setZoom(viewport.zoom); + }; - const interval = setInterval(updateZoomLevel, 100); + rfInstance.on('move', handleMove); - return () => clearInterval(interval); - }, [getZoom, snapToStep, setViewport, setZoom]); + return () => { + if (rfInstance && typeof rfInstance.off === 'function') { + rfInstance.off('move', handleMove); + } + }; + } else { + let rafId: number | null = null; + let lastZoom = rf.getZoom(); + let isCancelled = false; + + const updateZoomLevel = () => { + if (isCancelled) return; + + const currentViewport = rf.getViewport(); + const currentZoom = currentViewport.zoom; + + if (Math.abs(currentZoom - lastZoom) > 0.001) { + const snapped = snapToStep(currentZoom * 100); + setZoomLevel(Math.min(Math.max(snapped, 10), 200)); + setZoom(currentZoom); + lastZoom = currentZoom; + } - const animateZoom = useCallback( - (targetZoom: number, duration: number = 200) => { - const startZoom = getZoom(); - const currentViewport = getViewport(); - const startTime = performance.now(); - - const step = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const newZoom = startZoom + (targetZoom - startZoom) * progress; - - setViewport({ - zoom: newZoom, - x: currentViewport.x, - y: currentViewport.y, - }); - - if (progress < 1) { - requestAnimationFrame(step); - } else { - setZoomLevel(snapToStep(newZoom * 100)); - setZoom(newZoom); + rafId = requestAnimationFrame(updateZoomLevel); + }; + + const initialViewport = rf.getViewport(); + const snapped = snapToStep(initialViewport.zoom * 100); + setZoomLevel(Math.min(Math.max(snapped, 10), 200)); + setZoom(initialViewport.zoom); + lastZoom = initialViewport.zoom; + + rafId = requestAnimationFrame(updateZoomLevel); + + return () => { + isCancelled = true; + if (rafId !== null) { + cancelAnimationFrame(rafId); } }; + } + }, [rf, snapToStep, setZoom]); - requestAnimationFrame(step); + const animateZoom = useCallback( + (targetZoom: number, duration: number = 200) => { + const currentViewport = getViewport(); + setViewport({ ...currentViewport, zoom: targetZoom }, { duration }); }, - [getZoom, getViewport, setViewport, snapToStep, setZoom] + [setViewport, getViewport] ); const handleZoomIn = useCallback(() => { @@ -188,8 +268,8 @@ export const ZoomControls = memo( const getButtonStyles = useMemo( () => (buttonId: string, isActive = false) => ({ - minWidth: '40px', - height: '40px', + minWidth: `${controlSizes.buttonSize}px`, + height: `${controlSizes.buttonSize}px`, borderRadius: '12px', margin: '0 2px', background: isActive @@ -207,19 +287,18 @@ export const ZoomControls = memo( border: isActive ? 'none' : `1px solid ${theme === 'dark' ? 'rgba(148, 163, 184, 0.2)' : 'rgba(148, 163, 184, 0.3)'}`, - boxShadow: isActive - ? '0 4px 20px rgba(59, 130, 246, 0.4)' - : hoveredButton === buttonId - ? theme === 'dark' - ? '0 4px 20px rgba(0, 0, 0, 0.3)' - : '0 4px 20px rgba(0, 0, 0, 0.1)' - : 'none', - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transition: 'transform 0.18s ease', transform: hoveredButton === buttonId ? 'translateY(-2px) scale(1.05)' : 'translateY(0) scale(1)', - animation: isActive ? `${pulseGlow} 2s infinite` : 'none', backdropFilter: 'blur(10px)', willChange: hoveredButton === buttonId || isActive ? 'transform' : 'auto', // Optimize for animations + '&:focus': { + outline: 'none', + }, + '&:focus-visible': { + outline: '2px solid #2563eb', + outlineOffset: '2px', + }, '&:hover': { background: isActive ? 'linear-gradient(135deg, #2563eb, #4f46e5)' @@ -227,15 +306,9 @@ export const ZoomControls = memo( ? 'linear-gradient(135deg, #475569, #64748b)' : 'linear-gradient(135deg, #e2e8f0, #cbd5e1)', transform: 'translateY(-2px) scale(1.05)', - boxShadow: isActive - ? '0 6px 25px rgba(59, 130, 246, 0.5)' - : theme === 'dark' - ? '0 6px 25px rgba(0, 0, 0, 0.4)' - : '0 6px 25px rgba(0, 0, 0, 0.15)', - animation: `${wiggle} 0.5s ease-in-out`, }, }), - [theme, hoveredButton] + [controlSizes.buttonSize, theme, hoveredButton] ); // Optimize event handlers @@ -249,37 +322,68 @@ export const ZoomControls = memo( return ( {/* Group/Collapse Controls */} {showControls && ( - <> + {/* Separator */} {/* Zoom Controls */} - + - + {/* Zoom Level Display */} @@ -388,14 +488,15 @@ export const ZoomControls = memo( ? 'linear-gradient(135deg, #1e293b, #334155)' : 'linear-gradient(135deg, #f8fafc, #e2e8f0)', color: theme === 'dark' ? '#f1f5f9' : '#1e293b', - padding: '8px 12px', + padding: controlSizes.typographyPadding, borderRadius: '10px', textAlign: 'center', - minWidth: '60px', + minWidth: `${Math.round(56 * scaleFactor)}px`, + maxWidth: `${Math.round(66 * scaleFactor)}px`, cursor: 'pointer', userSelect: 'none', fontWeight: '600', - fontSize: '13px', + fontSize: controlSizes.typographyFontSize, border: `1px solid ${theme === 'dark' ? 'rgba(148, 163, 184, 0.2)' : 'rgba(148, 163, 184, 0.3)'}`, transition: 'all 0.3s ease', '&:hover': { @@ -466,19 +567,19 @@ export const ZoomControls = memo( {/* Separator */} {/* Edge Type Controls */} ( onChange={handleEdgeTypeChange} size="small" aria-label="Edge Type" + orientation="vertical" sx={{ borderRadius: '10px', overflow: 'hidden', @@ -511,10 +613,10 @@ export const ZoomControls = memo( transform: 'scale(1.02)', }, '&:first-of-type': { - borderRadius: '10px 0 0 10px', + borderRadius: '10px 10px 0 0', }, '&:last-of-type': { - borderRadius: '0 10px 10px 0', + borderRadius: '0 0 10px 10px', }, }, }} @@ -523,25 +625,35 @@ export const ZoomControls = memo( value="step" aria-label={t(`${translationPrefix}.zoomControls.square`)} sx={{ - px: 2, - py: 1, - minWidth: '44px', - height: '36px', + px: 1.6, + py: 0.4, + minWidth: `${Math.round(36 * scaleFactor)}px`, + height: controlSizes.toggleHeight, }} > - + - + @@ -551,13 +663,13 @@ export const ZoomControls = memo( {/* Separator */} ( ? t(`${translationPrefix}.zoomControls.exitFullscreen`) : t(`${translationPrefix}.zoomControls.fullscreen`) } - placement="bottom" + placement="left" arrow > )} - + )} {/* Button to show or hide the control panel */} @@ -591,17 +707,31 @@ export const ZoomControls = memo( ? t(`${translationPrefix}.hideControls.hide`) : t(`${translationPrefix}.hideControls.show`) } - placement="bottom" + placement="left" arrow >