|
|
import { useRef, useCallback } from 'react'; |
|
|
import * as d3 from 'd3'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const useZoom = (svgRef, darkMode, useCSSTransform = false) => { |
|
|
const zoomRef = useRef(); |
|
|
|
|
|
|
|
|
const setupZoom = useCallback((svg, viewportGroup, uiGroup, width, height) => { |
|
|
if (!svg || !viewportGroup) return null; |
|
|
|
|
|
|
|
|
const zoom = d3.zoom() |
|
|
.scaleExtent([0.6, 5.0]) |
|
|
.filter(function(event) { |
|
|
return !event.ctrlKey || event.type === 'wheel'; |
|
|
}) |
|
|
.on('start', function() { |
|
|
svg.classed('zooming', true); |
|
|
}) |
|
|
.on('zoom', (event) => { |
|
|
const { transform } = event; |
|
|
|
|
|
if (useCSSTransform) { |
|
|
|
|
|
viewportGroup.style('transform', `matrix(${transform.k}, 0, 0, ${transform.k}, ${transform.x}, ${transform.y})`); |
|
|
viewportGroup.attr('transform', null); |
|
|
} else { |
|
|
|
|
|
const matrix = transform.toString(); |
|
|
viewportGroup.attr('transform', matrix); |
|
|
viewportGroup.style('transform', null); |
|
|
} |
|
|
|
|
|
|
|
|
const scaleValue = transform.k; |
|
|
uiGroup.select('.zoom-indicator') |
|
|
.text(`Zoom: ${Math.round(scaleValue * 100)}%`); |
|
|
|
|
|
|
|
|
if (window.updateTooltipTransform) { |
|
|
window.updateTooltipTransform(transform); |
|
|
} |
|
|
|
|
|
if (window.updateTooltipPositions) { |
|
|
window.updateTooltipPositions(); |
|
|
} |
|
|
}) |
|
|
.on('end', function() { |
|
|
svg.classed('zooming', false); |
|
|
}); |
|
|
|
|
|
|
|
|
svg.call(zoom); |
|
|
|
|
|
|
|
|
const centerX = width / 2; |
|
|
const centerY = height / 2; |
|
|
const scale = 0.8; |
|
|
|
|
|
|
|
|
const translateX = centerX * (1 - scale); |
|
|
const translateY = centerY * (1 - scale); |
|
|
|
|
|
const initialTransform = d3.zoomIdentity |
|
|
.translate(translateX, translateY) |
|
|
.scale(scale); |
|
|
|
|
|
svg.call(zoom.transform, initialTransform); |
|
|
|
|
|
|
|
|
uiGroup.select('.zoom-indicator') |
|
|
.text(`Zoom: 80%`); |
|
|
|
|
|
|
|
|
zoomRef.current = zoom; |
|
|
return zoom; |
|
|
}, [useCSSTransform]); |
|
|
|
|
|
|
|
|
const setupGlobalZoomFunctions = useCallback((svg) => { |
|
|
window.zoomIn = () => { |
|
|
if (zoomRef.current) { |
|
|
svg.transition().duration(200).call(zoomRef.current.scaleBy, 1.5); |
|
|
} |
|
|
}; |
|
|
|
|
|
window.zoomOut = () => { |
|
|
if (zoomRef.current) { |
|
|
svg.transition().duration(200).call(zoomRef.current.scaleBy, 1 / 1.5); |
|
|
} |
|
|
}; |
|
|
|
|
|
window.resetZoom = () => { |
|
|
if (zoomRef.current) { |
|
|
|
|
|
const currentWidth = svg.attr('width') || svg.node().clientWidth; |
|
|
const currentHeight = svg.attr('height') || svg.node().clientHeight; |
|
|
|
|
|
|
|
|
const centerX = currentWidth / 2; |
|
|
const centerY = currentHeight / 2; |
|
|
const scale = 0.8; |
|
|
|
|
|
const translateX = centerX * (1 - scale); |
|
|
const translateY = centerY * (1 - scale); |
|
|
|
|
|
const resetTransform = d3.zoomIdentity |
|
|
.translate(translateX, translateY) |
|
|
.scale(scale); |
|
|
|
|
|
svg.transition().duration(300).call(zoomRef.current.transform, resetTransform); |
|
|
} |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
const centerOnFont = useCallback((selectedFont, positions, visualStateRef, setIsTransitioning) => { |
|
|
if (!selectedFont || !positions?.length || !zoomRef.current) return; |
|
|
|
|
|
|
|
|
const selectedPosition = positions.find(p => p.name === selectedFont.name); |
|
|
if (!selectedPosition) return; |
|
|
|
|
|
const svg = d3.select(svgRef.current); |
|
|
|
|
|
|
|
|
const svgElement = svgRef.current; |
|
|
const width = svgElement.clientWidth; |
|
|
const height = svgElement.clientHeight; |
|
|
|
|
|
|
|
|
setIsTransitioning(true); |
|
|
visualStateRef.current.isTransitioning = true; |
|
|
|
|
|
const scale = 2.5; |
|
|
const centerX = width / 2; |
|
|
const centerY = height / 2; |
|
|
|
|
|
const translateX = centerX - (selectedPosition.x * scale); |
|
|
const translateY = centerY - (selectedPosition.y * scale); |
|
|
|
|
|
const transform = d3.zoomIdentity |
|
|
.translate(translateX, translateY) |
|
|
.scale(scale); |
|
|
|
|
|
|
|
|
svg.transition() |
|
|
.duration(800) |
|
|
.ease(d3.easeCubicInOut) |
|
|
.call(zoomRef.current.transform, transform) |
|
|
.on('end', () => { |
|
|
|
|
|
setIsTransitioning(false); |
|
|
visualStateRef.current.isTransitioning = false; |
|
|
}); |
|
|
}, [svgRef]); |
|
|
|
|
|
|
|
|
const createZoomIndicator = useCallback((uiGroup, width, height, canNavigate = false) => { |
|
|
if (!uiGroup) return; |
|
|
|
|
|
|
|
|
const existingZoomIndicator = uiGroup.select('.zoom-indicator'); |
|
|
if (!existingZoomIndicator.empty()) { |
|
|
existingZoomIndicator |
|
|
.attr('y', Math.max(height - 20, 20)) |
|
|
.style('fill', darkMode ? '#ffffff' : '#000000'); |
|
|
} else { |
|
|
uiGroup.append('text') |
|
|
.attr('class', 'zoom-indicator') |
|
|
.attr('x', 20) |
|
|
.attr('y', Math.max(height - 20, 20)) |
|
|
.style('font-size', '12px') |
|
|
.style('fill', darkMode ? '#ffffff' : '#000000') |
|
|
.style('opacity', 0.7) |
|
|
.text('Zoom: 100%'); |
|
|
} |
|
|
|
|
|
|
|
|
const existingNavIndicator = uiGroup.select('.navigation-indicator'); |
|
|
if (canNavigate) { |
|
|
if (!existingNavIndicator.empty()) { |
|
|
existingNavIndicator |
|
|
.attr('y', Math.max(height - 40, 40)) |
|
|
.style('fill', darkMode ? '#ffffff' : '#000000') |
|
|
.style('opacity', 0.7); |
|
|
} else { |
|
|
uiGroup.append('text') |
|
|
.attr('class', 'navigation-indicator') |
|
|
.attr('x', 20) |
|
|
.attr('y', Math.max(height - 40, 40)) |
|
|
.style('font-size', '12px') |
|
|
.style('fill', darkMode ? '#ffffff' : '#000000') |
|
|
.style('opacity', 0.7) |
|
|
.text('↑↓←→ to navigate'); |
|
|
} |
|
|
} else { |
|
|
|
|
|
existingNavIndicator.remove(); |
|
|
} |
|
|
|
|
|
return existingZoomIndicator; |
|
|
}, [darkMode]); |
|
|
|
|
|
return { |
|
|
zoomRef, |
|
|
setupZoom, |
|
|
setupGlobalZoomFunctions, |
|
|
centerOnFont, |
|
|
createZoomIndicator |
|
|
}; |
|
|
}; |
|
|
|