fontmap / src /components /FontMap /hooks /useD3Visualization.js
tfrere's picture
tfrere HF Staff
first commit
eebc40f
raw
history blame
14 kB
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import * as d3 from 'd3';
// Import des hooks spécialisés
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';
/**
* Hook refactorisé pour la visualisation D3
* Utilise des hooks spécialisés pour une meilleure séparation des responsabilités
*/
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);
// Hooks spécialisés - utiliser useRef pour éviter les re-calculs constants
const positionsRef = useRef([]);
const hasPositionsRef = useRef(false);
// Recalculer les positions seulement quand nécessaire
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;
// Hooks d'optimisation
const viewportBoundsRef = useRef(null);
const { visiblePositions, getViewportBounds, totalCount, visibleCount } = useViewportCulling(positions, viewportBoundsRef.current);
const { getOpacity, invalidateCache } = useOpacityCache();
const { debouncedUpdate, flushUpdate } = useDebouncedUpdates(16); // 60fps
const {
visualStateRef,
updateVisualStates,
updateGlyphSizes,
updateGlyphOpacity,
updateGlyphColors
} = useVisualState();
const {
setupZoom,
setupGlobalZoomFunctions,
centerOnFont,
createZoomIndicator
} = useZoom(svgRef, darkMode, useCSSTransform);
const {
createGlyphs
} = useGlyphRenderer();
// 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, canNavigate);
// Créer les glyphes
createGlyphs(
viewportGroup,
positions,
darkMode,
characterSize,
filter,
searchTerm,
colorScale,
debugMode,
onFontSelect,
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,
onFontSelect,
selectedFont,
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]);
// 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;
// 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);
};
// Debouncer les mises à jour pour éviter les re-renders excessifs
debouncedUpdate(updateFn);
}, [selectedFont, hoveredFont, darkMode, characterSize, filter, searchTerm, fonts, loading, hasPositions, updateVisualStates, updateGlyphSizes, updateGlyphOpacity, updateGlyphColors, debouncedUpdate]);
// 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;
}, [dilationFactor, characterSize, filter, searchTerm, darkMode, fonts, loading, hasPositions, selectedFont, visualStateRef, compensateZoomForPositionChange, variantSizeImpact]);
// Effet pour centrer sur une police sélectionnée
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]);
// 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, canNavigate);
}
}, [canNavigate, 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;
};