|
|
import { useEffect, useRef, useCallback, useMemo } from 'react'; |
|
|
import * as d3 from 'd3'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const useTooltipOptimized = (darkMode) => { |
|
|
const tooltipRef = useRef(null); |
|
|
const selectedTooltipRef = useRef(null); |
|
|
const hoverTooltipRef = useRef(null); |
|
|
const currentTransformRef = useRef(d3.zoomIdentity); |
|
|
const imageLoadTimeoutsRef = useRef(new Map()); |
|
|
|
|
|
|
|
|
const tooltipStyles = useMemo(() => ({ |
|
|
dark: { |
|
|
backgroundColor: '#000000', |
|
|
borderColor: '#404040', |
|
|
color: '#ffffff' |
|
|
}, |
|
|
light: { |
|
|
backgroundColor: '#ffffff', |
|
|
borderColor: '#e0e0e0', |
|
|
color: '#000000' |
|
|
} |
|
|
}), []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
selectedTooltipRef.current = d3.select('body') |
|
|
.append('div') |
|
|
.attr('class', 'font-tooltip font-tooltip-selected') |
|
|
.style('opacity', 0) |
|
|
.style('position', 'absolute') |
|
|
.style('pointer-events', 'none') |
|
|
.style('z-index', 1001) |
|
|
.style('transition', 'opacity 0.2s ease'); |
|
|
|
|
|
|
|
|
hoverTooltipRef.current = d3.select('body') |
|
|
.append('div') |
|
|
.attr('class', 'font-tooltip font-tooltip-hover') |
|
|
.style('opacity', 0) |
|
|
.style('position', 'absolute') |
|
|
.style('pointer-events', 'none') |
|
|
.style('z-index', 1000) |
|
|
.style('transition', 'opacity 0.2s ease'); |
|
|
|
|
|
return () => { |
|
|
d3.selectAll('.font-tooltip').remove(); |
|
|
|
|
|
imageLoadTimeoutsRef.current.forEach(timeout => clearTimeout(timeout)); |
|
|
imageLoadTimeoutsRef.current.clear(); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const updateTooltipStyles = (tooltip) => { |
|
|
if (!tooltip) return; |
|
|
|
|
|
tooltip.classed('dark-mode', darkMode); |
|
|
|
|
|
const styles = darkMode ? tooltipStyles.dark : tooltipStyles.light; |
|
|
tooltip |
|
|
.style('background-color', styles.backgroundColor) |
|
|
.style('border-color', styles.borderColor) |
|
|
.style('color', styles.color); |
|
|
}; |
|
|
|
|
|
updateTooltipStyles(selectedTooltipRef.current); |
|
|
updateTooltipStyles(hoverTooltipRef.current); |
|
|
}, [darkMode, tooltipStyles]); |
|
|
|
|
|
|
|
|
const createTooltipContent = useCallback((font) => { |
|
|
const imageName = font.imageName || font.name; |
|
|
const sentenceImagePath = `/data/sentences/${imageName.toLowerCase().replace(/\s+/g, '_')}_sentence.svg`; |
|
|
const textColor = darkMode ? '#ffffff' : '#000000'; |
|
|
const imageFilter = darkMode ? 'invert(1)' : 'none'; |
|
|
|
|
|
return ` |
|
|
<div class="simple-tooltip"> |
|
|
<div class="tooltip-font-name" style="color: ${textColor} !important;">${font.name}</div> |
|
|
<div class="tooltip-sentence-preview"> |
|
|
<div class="tooltip-image-container" style="position: relative; min-height: 44px; display: flex; align-items: center; justify-content: center;"> |
|
|
<img src="${sentenceImagePath}" |
|
|
alt="${font.name} sentence preview" |
|
|
class="sentence-image" |
|
|
style="filter: ${imageFilter}; max-width: 200px; height: auto; opacity: 0; transition: opacity 0.2s ease;" |
|
|
onload="this.style.opacity='1'; this.parentElement.querySelector('.tooltip-spinner').style.display='none';" |
|
|
onerror="this.style.display='none'; this.parentElement.querySelector('.tooltip-spinner').style.display='flex'; this.parentElement.querySelector('.tooltip-spinner').innerHTML='⚠️ Loading error';" |
|
|
/> |
|
|
<div class="tooltip-spinner" style="display: flex; align-items: center; justify-content: center; color: #999; font-size: 12px; position: absolute; top: 0; left: 0; right: 0; bottom: 0;"> |
|
|
<div style="width: 16px; height: 16px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div> |
|
|
Loading... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}, [darkMode]); |
|
|
|
|
|
|
|
|
const positionTooltip = useCallback((tooltip, svgElement) => { |
|
|
if (!tooltip || !svgElement) return; |
|
|
|
|
|
const tooltipNode = tooltip.node(); |
|
|
const tooltipRect = tooltipNode.getBoundingClientRect(); |
|
|
const elementRect = svgElement.getBoundingClientRect(); |
|
|
|
|
|
|
|
|
const centerX = elementRect.left + (elementRect.width / 2); |
|
|
const centerY = elementRect.top + (elementRect.height / 2); |
|
|
|
|
|
|
|
|
let x = centerX - (tooltipRect.width / 2); |
|
|
let y = centerY - tooltipRect.height - 20; |
|
|
|
|
|
|
|
|
const margin = 10; |
|
|
if (x < margin) x = margin; |
|
|
if (x + tooltipRect.width > window.innerWidth - margin) { |
|
|
x = window.innerWidth - tooltipRect.width - margin; |
|
|
} |
|
|
if (y < margin) { |
|
|
y = centerY + 30; |
|
|
} |
|
|
|
|
|
tooltip |
|
|
.style('left', `${x}px`) |
|
|
.style('top', `${y}px`); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const showTooltip = useCallback((tooltip, font, svgElement) => { |
|
|
if (!tooltip || !font) return; |
|
|
|
|
|
tooltip |
|
|
.html(createTooltipContent(font)) |
|
|
.style('opacity', 1); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
positionTooltip(tooltip, svgElement); |
|
|
}, 10); |
|
|
}, [createTooltipContent, positionTooltip]); |
|
|
|
|
|
|
|
|
const hideTooltip = useCallback((tooltip) => { |
|
|
if (!tooltip) return; |
|
|
tooltip.style('opacity', 0); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const updatePositions = useCallback(() => { |
|
|
const svg = d3.select('.fontmap-svg'); |
|
|
if (svg.empty()) return; |
|
|
|
|
|
const viewportGroup = svg.select('.viewport-group'); |
|
|
if (viewportGroup.empty()) return; |
|
|
|
|
|
|
|
|
if (selectedTooltipRef.current && selectedTooltipRef.current.style('opacity') !== '0') { |
|
|
const selectedGlyph = viewportGroup.selectAll('.font-glyph-group') |
|
|
.filter(function(d) { |
|
|
return d && d.name && window.currentSelectedFont && d.name === window.currentSelectedFont.name; |
|
|
}); |
|
|
|
|
|
if (!selectedGlyph.empty()) { |
|
|
const glyphElement = selectedGlyph.node(); |
|
|
positionTooltip(selectedTooltipRef.current, glyphElement); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (hoverTooltipRef.current && hoverTooltipRef.current.style('opacity') !== '0') { |
|
|
const hoveredGlyph = viewportGroup.selectAll('.font-glyph-group') |
|
|
.filter(function(d) { |
|
|
return d && d.name && window.currentHoveredFont && d.name === window.currentHoveredFont.name; |
|
|
}); |
|
|
|
|
|
if (!hoveredGlyph.empty()) { |
|
|
const glyphElement = hoveredGlyph.node(); |
|
|
positionTooltip(hoverTooltipRef.current, glyphElement); |
|
|
} |
|
|
} |
|
|
}, [positionTooltip]); |
|
|
|
|
|
|
|
|
const updateTransform = useCallback((transform) => { |
|
|
currentTransformRef.current = transform; |
|
|
setTimeout(() => { |
|
|
updatePositions(); |
|
|
}, 0); |
|
|
}, [updatePositions]); |
|
|
|
|
|
|
|
|
const handleFontSelect = useCallback((font, svgElement) => { |
|
|
if (!font) { |
|
|
hideTooltip(selectedTooltipRef.current); |
|
|
return; |
|
|
} |
|
|
|
|
|
hideTooltip(hoverTooltipRef.current); |
|
|
showTooltip(selectedTooltipRef.current, font, svgElement); |
|
|
}, [showTooltip, hideTooltip]); |
|
|
|
|
|
|
|
|
const handleFontHover = useCallback((font, svgElement) => { |
|
|
if (!font) { |
|
|
hideTooltip(hoverTooltipRef.current); |
|
|
return; |
|
|
} |
|
|
|
|
|
showTooltip(hoverTooltipRef.current, font, svgElement); |
|
|
}, [showTooltip, hideTooltip]); |
|
|
|
|
|
|
|
|
const handleFontUnhover = useCallback(() => { |
|
|
hideTooltip(hoverTooltipRef.current); |
|
|
}, [hideTooltip]); |
|
|
|
|
|
return { |
|
|
handleFontSelect, |
|
|
handleFontHover, |
|
|
handleFontUnhover, |
|
|
updateTransform, |
|
|
updatePositions |
|
|
}; |
|
|
}; |
|
|
|