import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import * as d3 from 'd3'; // Import des hooks spécialisés 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 { useFontMapStore } from '../../../store/fontMapStore'; /** * Hook refactorisé pour la visualisation D3 * Utilise des hooks spécialisés pour une meilleure séparation des responsabilités * Maintenant utilise Zustand pour éviter le props drilling */ export const useD3Visualization = ( fonts, filter, searchTerm, darkMode, loading ) => { // Récupérer l'état depuis le store Zustand const { characterSize, selectedFont, hoveredFont, variantSizeImpact, setSelectedFont } = useFontMapStore(); 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); // Hooks spécialisés - utiliser useRef pour éviter les re-calculs constants const positionsRef = useRef([]); const hasPositionsRef = useRef(false); // Calculer les positions UMAP directement (sans dilatation) useEffect(() => { if (fonts.length && dimensions.width > 0 && dimensions.height > 0) { console.log('🔄 Calculating UMAP positions'); // Créer les échelles pour mapper UMAP vers les dimensions de l'écran const xScale = d3.scaleLinear() .domain(d3.extent(fonts, d => d.x)) .range([50, dimensions.width - 50]); const yScale = d3.scaleLinear() .domain(d3.extent(fonts, d => d.y)) .range([50, dimensions.height - 50]); // Mapper les positions UMAP aux coordonnées écran positionsRef.current = fonts.map(font => ({ ...font, x: xScale(font.x), y: yScale(font.y) })); hasPositionsRef.current = positionsRef.current.length > 0; console.log('✅ UMAP positions calculated, count:', positionsRef.current.length); } }, [fonts, dimensions.width, dimensions.height]); const positions = positionsRef.current; const hasPositions = hasPositionsRef.current; // Hooks d'optimisation const viewportBoundsRef = useRef(null); const { visiblePositions, getViewportBounds } = useViewportCulling(positions, viewportBoundsRef.current); const { getOpacity } = useOpacityCache(); const { debouncedUpdate } = useDebouncedUpdates(16); // 60fps // Hook pour le rendu des glyphes const { createGlyphs, updateGlyphPositions } = useGlyphRenderer(); const { visualStateRef, updateVisualStates, updateGlyphSizes, updateGlyphOpacity, updateGlyphColors } = useVisualState(); const { setupZoom, setupGlobalZoomFunctions, centerOnFont, createZoomIndicator } = useZoom(svgRef, darkMode, useCSSTransform); // createGlyphs est déjà extrait au-dessus // Mémoriser la configuration des couleurs 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]); // Fonction pour initialiser la visualisation const initializeVisualization = useCallback(() => { if (loading || !fonts.length || !hasPositions || dimensions.width <= 0 || dimensions.height <= 0) { return; } const svg = d3.select(svgRef.current); // Optimisation du rendu pour qualité vectorielle svg.style('shape-rendering', 'geometricPrecision') .style('text-rendering', 'geometricPrecision') .style('image-rendering', 'crisp-edges') .style('vector-effect', 'non-scaling-stroke'); // Nettoyer le SVG si c'est la première initialisation if (!isInitializedRef.current) { svg.selectAll('*').remove(); isInitializedRef.current = true; } // Créer les groupes principaux 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'); // Force le rendu vectoriel sur le viewport group viewportGroup.style('shape-rendering', 'geometricPrecision') .style('text-rendering', 'geometricPrecision') .style('image-rendering', 'crisp-edges'); } // Configurer le zoom setupZoom(svg, viewportGroup, uiGroup, dimensions.width, dimensions.height); // Configurer les fonctions de zoom globales setupGlobalZoomFunctions(svg); // Créer l'indicateur de zoom et de navigation createZoomIndicator(uiGroup, dimensions.width, dimensions.height, selectedFont !== null); // Créer les glyphes createGlyphs( viewportGroup, positions, darkMode, characterSize, filter, searchTerm, colorScale, debugMode, setSelectedFont, // Utiliser la fonction du store selectedFont, visualStateRef ); // Gérer le redimensionnement 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, setSelectedFont, selectedFont, positions, visualStateRef, setupZoom, setupGlobalZoomFunctions, createZoomIndicator, createGlyphs ]); // Effet pour l'initialisation (une seule fois) useEffect(() => { if (loading || !fonts.length || !hasPositions) return; const timer = setTimeout(() => { const svg = d3.select(svgRef.current); // Vérifier si déjà initialisé if (svg.select('.viewport-group').empty()) { initializeVisualization(); } }, 100); return () => clearTimeout(timer); }, [loading, fonts.length, hasPositions, dimensions.width, dimensions.height, darkMode, useCSSTransform, initializeVisualization]); // Effet unifié optimisé pour toutes les mises à jour visuelles 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; console.log('🔄 Updating visualization with positions:', positions.length); // Mise à jour unique et optimisée de tous les états visuels updateVisualStates(svg, viewportGroup, selectedFont, hoveredFont, darkMode); updateGlyphSizes(viewportGroup, selectedFont, characterSize); updateGlyphOpacity(viewportGroup, positions, filter, searchTerm, selectedFont); updateGlyphColors(viewportGroup, fonts, darkMode); // IMPORTANT: Mettre à jour les positions des glyphes updateGlyphPositions(viewportGroup, positions); }; // Debouncer les mises à jour pour éviter les re-renders excessifs debouncedUpdate(updateFn); }, [selectedFont, hoveredFont, darkMode, characterSize, filter, searchTerm, fonts, loading, hasPositions, positions, updateVisualStates, updateGlyphSizes, updateGlyphOpacity, updateGlyphColors, updateGlyphPositions, debouncedUpdate]); // Effet spécifique pour les changements de positions (dilatation) useEffect(() => { if (loading || !fonts.length || !hasPositions) return; const svg = d3.select(svgRef.current); const viewportGroup = svg.select('.viewport-group'); if (viewportGroup.empty()) return; console.log('🎯 Positions changed - updating glyph positions immediately'); // Mise à jour immédiate des positions (sans debounce pour la dilatation) updateGlyphPositions(viewportGroup, positions); // Stocker les positions précédentes pour la compensation de zoom previousPositionsRef.current = positions; }, [positions, loading, fonts.length, hasPositions, updateGlyphPositions]); // Fonction pour compenser le zoom lors des changements de positions const compensateZoomForPositionChange = useCallback((svg, viewportGroup, previousPositions, newPositions) => { if (!previousPositions || !newPositions || previousPositions.length === 0 || newPositions.length === 0) return; // Calculer le centre des positions précédentes const prevCenterX = d3.mean(previousPositions, d => d.x); const prevCenterY = d3.mean(previousPositions, d => d.y); // Calculer le centre des nouvelles positions const newCenterX = d3.mean(newPositions, d => d.x); const newCenterY = d3.mean(newPositions, d => d.y); // Calculer le décalage const offsetX = prevCenterX - newCenterX; const offsetY = prevCenterY - newCenterY; // Obtenir la transformation actuelle const currentTransform = d3.zoomTransform(svg.node()); // Appliquer la compensation immédiatement const compensatedTransform = currentTransform.translate(offsetX, offsetY); svg.call(d3.zoom().transform, compensatedTransform); }, []); // Effet optimisé pour les changements de propriétés (dilatation, taille, filtre, mode sombre) // NE PAS inclure positions dans les dépendances pour éviter les re-renders constants 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; } // Compenser le zoom seulement si les positions ont vraiment changé (pas juste le zoom/pan) 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); } // Mise à jour optimisée des glyphes - seulement les positions si elles ont changé const glyphGroups = viewportGroup.selectAll('.font-glyph-group'); const fontGlyphs = viewportGroup.selectAll('.font-glyph'); // Mettre à jour les bounds du viewport viewportBoundsRef.current = getViewportBounds(svg, viewportGroup); const baseSize = 16; let currentSize = baseSize * characterSize; // Ajuster la taille en fonction du nombre de variantes si activé if (variantSizeImpact) { // Calculer un multiplicateur basé sur le nombre de variantes const variantMultiplier = Math.max(0.5, Math.min(2.0, 1 + (positions.length / fonts.length) * 0.5)); currentSize *= variantMultiplier; } const offset = currentSize / 2; // Vérifier si la taille a vraiment changé pour éviter le flicker const sizeChanged = !lastSizeRef.current || Math.abs(lastSizeRef.current - currentSize) > 0.1; // Mise à jour des positions uniquement si nécessaire (pas à chaque mouvement de souris) 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) // Utiliser seulement les positions visibles .attr('transform', d => `translate(${d.x}, ${d.y})`); } // Mise à jour de l'opacité avec cache glyphGroups .style('opacity', d => getOpacity(d, filter, searchTerm, selectedFont)); // Mise à jour de la taille seulement si elle a changé if (sizeChanged) { fontGlyphs .transition() .duration(200) // Transition douce pour éviter le flicker .ease(d3.easeCubicOut) .attr('width', currentSize) .attr('height', currentSize) .attr('x', -offset) .attr('y', -offset); lastSizeRef.current = currentSize; } isUpdatingRef.current = false; previousPositionsRef.current = positions; }, [characterSize, filter, searchTerm, darkMode, fonts, loading, hasPositions, selectedFont, visualStateRef, compensateZoomForPositionChange, variantSizeImpact, getOpacity, getViewportBounds, positions, visiblePositions]); // Effet pour centrer sur une police sélectionnée useEffect(() => { if (loading || !fonts.length || !hasPositions || !selectedFont) return; centerOnFont(selectedFont, positions, visualStateRef, setIsTransitioning); }, [selectedFont, positions, loading, hasPositions, fonts.length, centerOnFont, visualStateRef]); // Effet pour mettre à jour l'indicateur de navigation 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, selectedFont !== null); } }, [selectedFont, loading, fonts.length, hasPositions, dimensions, createZoomIndicator]); // Gestion du mode debug avec la touche 'd' et basculement CSS transform avec 't' 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]); // Initialisation des dimensions useEffect(() => { const container = svgRef.current?.parentElement; if (container) { const width = container.clientWidth; const height = container.clientHeight; setDimensions({ width, height }); } }, []); // Effet pour pré-calculer les positions dilatées au démarrage 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; // Plus de pré-calcul nécessaire avec la nouvelle approche simple }, [fonts, loading]); return svgRef; };