|
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; |
|
|
import * as d3 from 'd3'; |
|
|
|
|
|
|
|
|
import { usePositioning } from './usePositioning'; |
|
|
import { useVisualState } from './useVisualState'; |
|
|
import { useZoom } from './useZoom'; |
|
|
import { useGlyphRenderer } from './useGlyphRenderer'; |
|
|
import { useViewportCulling } from './useViewportCulling'; |
|
|
import { useOpacityCache } from './useOpacityCache'; |
|
|
import { useDebouncedUpdates } from './useDebouncedUpdates'; |
|
|
import { calculatePositions } from '../utils/voronoiDilation'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const useD3Visualization = ( |
|
|
fonts, |
|
|
filter, |
|
|
searchTerm, |
|
|
darkMode, |
|
|
loading, |
|
|
dilationFactor = 0.8, |
|
|
characterSize = 1.0, |
|
|
onFontSelect = null, |
|
|
selectedFont = null, |
|
|
hoveredFont = null, |
|
|
zoomLevel = 0.9, |
|
|
variantSizeImpact = false, |
|
|
canNavigate = false |
|
|
) => { |
|
|
const svgRef = useRef(); |
|
|
const [debugMode, setDebugMode] = useState(false); |
|
|
const [useCSSTransform, setUseCSSTransform] = useState(false); |
|
|
const [, setIsTransitioning] = useState(false); |
|
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); |
|
|
const isInitializedRef = useRef(false); |
|
|
const isUpdatingRef = useRef(false); |
|
|
const previousPositionsRef = useRef(null); |
|
|
const lastSizeRef = useRef(null); |
|
|
|
|
|
|
|
|
const positionsRef = useRef([]); |
|
|
const hasPositionsRef = useRef(false); |
|
|
|
|
|
|
|
|
useMemo(() => { |
|
|
if (fonts.length && dimensions.width > 0 && dimensions.height > 0) { |
|
|
positionsRef.current = calculatePositions(fonts, dimensions.width, dimensions.height, dilationFactor); |
|
|
hasPositionsRef.current = positionsRef.current.length > 0; |
|
|
} |
|
|
}, [fonts, dimensions.width, dimensions.height, dilationFactor]); |
|
|
|
|
|
const positions = positionsRef.current; |
|
|
const hasPositions = hasPositionsRef.current; |
|
|
|
|
|
|
|
|
const viewportBoundsRef = useRef(null); |
|
|
const { visiblePositions, getViewportBounds, totalCount, visibleCount } = useViewportCulling(positions, viewportBoundsRef.current); |
|
|
const { getOpacity, invalidateCache } = useOpacityCache(); |
|
|
const { debouncedUpdate, flushUpdate } = useDebouncedUpdates(16); |
|
|
|
|
|
const { |
|
|
visualStateRef, |
|
|
updateVisualStates, |
|
|
updateGlyphSizes, |
|
|
updateGlyphOpacity, |
|
|
updateGlyphColors |
|
|
} = useVisualState(); |
|
|
|
|
|
const { |
|
|
setupZoom, |
|
|
setupGlobalZoomFunctions, |
|
|
centerOnFont, |
|
|
createZoomIndicator |
|
|
} = useZoom(svgRef, darkMode, useCSSTransform); |
|
|
|
|
|
const { |
|
|
createGlyphs |
|
|
} = useGlyphRenderer(); |
|
|
|
|
|
|
|
|
const colorScale = useMemo(() => { |
|
|
const scale = d3.scaleOrdinal( |
|
|
darkMode |
|
|
? ['#ffffff', '#cccccc', '#999999', '#666666', '#333333'] |
|
|
: ['#000000', '#333333', '#666666', '#999999', '#cccccc'] |
|
|
); |
|
|
const families = [...new Set(fonts.map(d => d.family))]; |
|
|
families.forEach(family => scale(family)); |
|
|
return scale; |
|
|
}, [fonts, darkMode]); |
|
|
|
|
|
|
|
|
const initializeVisualization = useCallback(() => { |
|
|
if (loading || !fonts.length || !hasPositions || dimensions.width <= 0 || dimensions.height <= 0) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const svg = d3.select(svgRef.current); |
|
|
|
|
|
|
|
|
svg.style('shape-rendering', 'geometricPrecision') |
|
|
.style('text-rendering', 'geometricPrecision') |
|
|
.style('image-rendering', 'crisp-edges') |
|
|
.style('vector-effect', 'non-scaling-stroke'); |
|
|
|
|
|
|
|
|
if (!isInitializedRef.current) { |
|
|
svg.selectAll('*').remove(); |
|
|
isInitializedRef.current = true; |
|
|
} |
|
|
|
|
|
|
|
|
let uiGroup = svg.select('.ui-group'); |
|
|
let viewportGroup = svg.select('.viewport-group'); |
|
|
|
|
|
if (uiGroup.empty()) { |
|
|
uiGroup = svg.append('g').attr('class', 'ui-group'); |
|
|
} |
|
|
if (viewportGroup.empty()) { |
|
|
viewportGroup = svg.append('g').attr('class', 'viewport-group'); |
|
|
|
|
|
|
|
|
viewportGroup.style('shape-rendering', 'geometricPrecision') |
|
|
.style('text-rendering', 'geometricPrecision') |
|
|
.style('image-rendering', 'crisp-edges'); |
|
|
} |
|
|
|
|
|
|
|
|
setupZoom(svg, viewportGroup, uiGroup, dimensions.width, dimensions.height); |
|
|
|
|
|
|
|
|
setupGlobalZoomFunctions(svg); |
|
|
|
|
|
|
|
|
createZoomIndicator(uiGroup, dimensions.width, dimensions.height, canNavigate); |
|
|
|
|
|
|
|
|
createGlyphs( |
|
|
viewportGroup, |
|
|
positions, |
|
|
darkMode, |
|
|
characterSize, |
|
|
filter, |
|
|
searchTerm, |
|
|
colorScale, |
|
|
debugMode, |
|
|
onFontSelect, |
|
|
selectedFont, |
|
|
visualStateRef |
|
|
); |
|
|
|
|
|
|
|
|
const handleResize = () => { |
|
|
const container = svgRef.current?.parentElement; |
|
|
if (container) { |
|
|
const newWidth = container.clientWidth; |
|
|
const newHeight = container.clientHeight; |
|
|
setDimensions({ width: newWidth, height: newHeight }); |
|
|
svg.attr('width', newWidth).attr('height', newHeight); |
|
|
} |
|
|
}; |
|
|
|
|
|
window.addEventListener('resize', handleResize); |
|
|
|
|
|
return () => { |
|
|
window.removeEventListener('resize', handleResize); |
|
|
}; |
|
|
}, [ |
|
|
loading, |
|
|
fonts.length, |
|
|
hasPositions, |
|
|
dimensions, |
|
|
darkMode, |
|
|
characterSize, |
|
|
filter, |
|
|
searchTerm, |
|
|
colorScale, |
|
|
debugMode, |
|
|
onFontSelect, |
|
|
selectedFont, |
|
|
setupZoom, |
|
|
setupGlobalZoomFunctions, |
|
|
createZoomIndicator, |
|
|
createGlyphs |
|
|
]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length || !hasPositions) return; |
|
|
|
|
|
const timer = setTimeout(() => { |
|
|
const svg = d3.select(svgRef.current); |
|
|
|
|
|
|
|
|
if (svg.select('.viewport-group').empty()) { |
|
|
initializeVisualization(); |
|
|
} |
|
|
}, 100); |
|
|
return () => clearTimeout(timer); |
|
|
}, [loading, fonts.length, hasPositions, dimensions.width, dimensions.height, darkMode, useCSSTransform]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length || !hasPositions) return; |
|
|
|
|
|
const updateFn = () => { |
|
|
const svg = d3.select(svgRef.current); |
|
|
const viewportGroup = svg.select('.viewport-group'); |
|
|
|
|
|
if (viewportGroup.empty()) return; |
|
|
|
|
|
|
|
|
updateVisualStates(svg, viewportGroup, selectedFont, hoveredFont, darkMode); |
|
|
updateGlyphSizes(viewportGroup, selectedFont, characterSize); |
|
|
updateGlyphOpacity(viewportGroup, positions, filter, searchTerm, selectedFont); |
|
|
updateGlyphColors(viewportGroup, fonts, darkMode); |
|
|
}; |
|
|
|
|
|
|
|
|
debouncedUpdate(updateFn); |
|
|
}, [selectedFont, hoveredFont, darkMode, characterSize, filter, searchTerm, fonts, loading, hasPositions, updateVisualStates, updateGlyphSizes, updateGlyphOpacity, updateGlyphColors, debouncedUpdate]); |
|
|
|
|
|
|
|
|
const compensateZoomForPositionChange = useCallback((svg, viewportGroup, previousPositions, newPositions) => { |
|
|
if (!previousPositions || !newPositions || previousPositions.length === 0 || newPositions.length === 0) return; |
|
|
|
|
|
|
|
|
const prevCenterX = d3.mean(previousPositions, d => d.x); |
|
|
const prevCenterY = d3.mean(previousPositions, d => d.y); |
|
|
|
|
|
|
|
|
const newCenterX = d3.mean(newPositions, d => d.x); |
|
|
const newCenterY = d3.mean(newPositions, d => d.y); |
|
|
|
|
|
|
|
|
const offsetX = prevCenterX - newCenterX; |
|
|
const offsetY = prevCenterY - newCenterY; |
|
|
|
|
|
|
|
|
const currentTransform = d3.zoomTransform(svg.node()); |
|
|
|
|
|
|
|
|
const compensatedTransform = currentTransform.translate(offsetX, offsetY); |
|
|
svg.call(d3.zoom().transform, compensatedTransform); |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length || !hasPositions) return; |
|
|
if (isUpdatingRef.current) return; |
|
|
if (visualStateRef.current.isTransitioning) return; |
|
|
|
|
|
isUpdatingRef.current = true; |
|
|
|
|
|
const svg = d3.select(svgRef.current); |
|
|
const viewportGroup = svg.select('.viewport-group'); |
|
|
|
|
|
if (viewportGroup.empty()) { |
|
|
isUpdatingRef.current = false; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (previousPositionsRef.current && previousPositionsRef.current.length > 0 && |
|
|
previousPositionsRef.current.length === positions.length && |
|
|
Math.abs(previousPositionsRef.current[0]?.x - positions[0]?.x) > 1) { |
|
|
compensateZoomForPositionChange(svg, viewportGroup, previousPositionsRef.current, positions); |
|
|
} |
|
|
|
|
|
|
|
|
const glyphGroups = viewportGroup.selectAll('.font-glyph-group'); |
|
|
const fontGlyphs = viewportGroup.selectAll('.font-glyph'); |
|
|
|
|
|
|
|
|
viewportBoundsRef.current = getViewportBounds(svg, viewportGroup); |
|
|
|
|
|
const baseSize = 16; |
|
|
let currentSize = baseSize * characterSize; |
|
|
|
|
|
|
|
|
if (variantSizeImpact) { |
|
|
|
|
|
const variantMultiplier = Math.max(0.5, Math.min(2.0, 1 + (positions.length / fonts.length) * 0.5)); |
|
|
currentSize *= variantMultiplier; |
|
|
} |
|
|
|
|
|
const offset = currentSize / 2; |
|
|
|
|
|
|
|
|
const sizeChanged = !lastSizeRef.current || Math.abs(lastSizeRef.current - currentSize) > 0.1; |
|
|
|
|
|
|
|
|
if (previousPositionsRef.current?.length !== positions.length || |
|
|
(previousPositionsRef.current?.[0]?.x !== positions[0]?.x && |
|
|
Math.abs(previousPositionsRef.current?.[0]?.x - positions[0]?.x) > 1)) { |
|
|
glyphGroups |
|
|
.data(visiblePositions) |
|
|
.attr('transform', d => `translate(${d.x}, ${d.y})`); |
|
|
} |
|
|
|
|
|
|
|
|
glyphGroups |
|
|
.style('opacity', d => getOpacity(d, filter, searchTerm, selectedFont)); |
|
|
|
|
|
|
|
|
if (sizeChanged) { |
|
|
fontGlyphs |
|
|
.transition() |
|
|
.duration(200) |
|
|
.ease(d3.easeCubicOut) |
|
|
.attr('width', currentSize) |
|
|
.attr('height', currentSize) |
|
|
.attr('x', -offset) |
|
|
.attr('y', -offset); |
|
|
|
|
|
lastSizeRef.current = currentSize; |
|
|
} |
|
|
|
|
|
isUpdatingRef.current = false; |
|
|
previousPositionsRef.current = positions; |
|
|
|
|
|
}, [dilationFactor, characterSize, filter, searchTerm, darkMode, fonts, loading, hasPositions, selectedFont, visualStateRef, compensateZoomForPositionChange, variantSizeImpact]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length || !hasPositions || !selectedFont) return; |
|
|
|
|
|
centerOnFont(selectedFont, fonts, dimensions.width, dimensions.height, dilationFactor, visualStateRef, setIsTransitioning); |
|
|
}, [selectedFont, fonts, loading, hasPositions, dimensions, dilationFactor, centerOnFont, visualStateRef]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length || !hasPositions) return; |
|
|
|
|
|
const svg = d3.select(svgRef.current); |
|
|
const uiGroup = svg.select('.ui-group'); |
|
|
|
|
|
if (!uiGroup.empty()) { |
|
|
createZoomIndicator(uiGroup, dimensions.width, dimensions.height, canNavigate); |
|
|
} |
|
|
}, [canNavigate, loading, fonts.length, hasPositions, dimensions, createZoomIndicator]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const handleKeyPress = (event) => { |
|
|
if (event.key === 'd' || event.key === 'D') { |
|
|
setDebugMode(prev => !prev); |
|
|
} |
|
|
if (event.key === 't' || event.key === 'T') { |
|
|
setUseCSSTransform(prev => !prev); |
|
|
console.log('CSS Transform mode:', !useCSSTransform); |
|
|
} |
|
|
}; |
|
|
|
|
|
window.addEventListener('keydown', handleKeyPress); |
|
|
return () => window.removeEventListener('keydown', handleKeyPress); |
|
|
}, [useCSSTransform]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const container = svgRef.current?.parentElement; |
|
|
if (container) { |
|
|
const width = container.clientWidth; |
|
|
const height = container.clientHeight; |
|
|
setDimensions({ width, height }); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (loading || !fonts.length) return; |
|
|
|
|
|
const container = svgRef.current?.parentElement; |
|
|
if (!container) return; |
|
|
|
|
|
const width = container.clientWidth; |
|
|
const height = container.clientHeight; |
|
|
|
|
|
if (width === 0 || height === 0) return; |
|
|
|
|
|
|
|
|
}, [fonts, loading]); |
|
|
|
|
|
return svgRef; |
|
|
}; |
|
|
|