import * as d3 from 'd3'; import { forceSimulation, forceManyBody, forceCollide } from 'd3-force'; // Cache pour éviter de recréer les simulations let simulationCache = new Map(); let lastPositionsHash = null; /** * Calcule la bounding box d'un groupe de positions * @param {Array} positions - Positions des polices * @returns {Object} Bounding box {minX, maxX, minY, maxY, width, height, centerX, centerY} */ const calculateBoundingBox = (positions) => { if (!positions || positions.length === 0) { return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0, centerX: 0, centerY: 0 }; } const xs = positions.map(p => p.x); const ys = positions.map(p => p.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); return { minX, maxX, minY, maxY, width: maxX - minX, height: maxY - minY, centerX: (minX + maxX) / 2, centerY: (minY + maxY) / 2 }; }; /** * Génère un hash simple pour les positions (pour le cache) * @param {Array} positions - Positions des polices * @returns {string} Hash des positions */ const generatePositionsHash = (positions) => { return positions.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join('|'); }; /** * Applique une dilatation optimisée avec cache et interpolation * @param {Array} positions - Positions initiales des polices * @param {number} sliderValue - Valeur du slider (0-1) * @returns {Array} Positions après simulation */ export const applySimpleDilation = (positions, sliderValue) => { if (!positions || positions.length === 0 || sliderValue === 0) { return positions; } // Configuration basée sur la valeur du slider (0-1 → équivalent 0-0.35) const mappedValue = sliderValue * 0.35; // Points de cache pour interpolation const cachePoints = [0, 0.1, 0.2, 0.3, 0.35]; const cacheKey = Math.min(...cachePoints.filter(p => p >= mappedValue)); // Vérifier si on a déjà calculé cette valeur const positionsHash = generatePositionsHash(positions); const cacheEntryKey = `${positionsHash}-${cacheKey}`; if (simulationCache.has(cacheEntryKey) && lastPositionsHash === positionsHash) { const cachedResult = simulationCache.get(cacheEntryKey); // Si c'est exactement la valeur cachée, la retourner directement if (Math.abs(mappedValue - cacheKey) < 0.001) { return cachedResult; } // Sinon, interpoler avec la valeur précédente const prevCacheKey = cachePoints[cachePoints.indexOf(cacheKey) - 1]; if (prevCacheKey !== undefined) { const prevCacheEntryKey = `${positionsHash}-${prevCacheKey}`; if (simulationCache.has(prevCacheEntryKey)) { const prevResult = simulationCache.get(prevCacheEntryKey); const interpolationFactor = (mappedValue - prevCacheKey) / (cacheKey - prevCacheKey); return interpolatePositions(prevResult, cachedResult, interpolationFactor); } } } // Calculer la nouvelle simulation si pas en cache console.log('🎯 Computing new simulation for slider:', sliderValue, 'mapped:', mappedValue); const result = computeSimulation(positions, mappedValue); // Mettre en cache simulationCache.set(cacheEntryKey, result); lastPositionsHash = positionsHash; // Limiter la taille du cache if (simulationCache.size > 10) { const firstKey = simulationCache.keys().next().value; simulationCache.delete(firstKey); } return result; }; /** * Calcule une simulation D3 pour une valeur donnée * @param {Array} positions - Positions initiales * @param {number} mappedValue - Valeur mappée (0-0.35) * @returns {Array} Positions après simulation */ const computeSimulation = (positions, mappedValue) => { // Calculer la bounding box initiale const initialBBox = calculateBoundingBox(positions); // Créer une copie des positions pour la simulation const simulationData = positions.map(pos => ({ x: pos.x, y: pos.y, name: pos.name, originalX: pos.x, originalY: pos.y })); const maxSteps = 100; const simulationSteps = Math.round(mappedValue * maxSteps); const forceStrength = -mappedValue * 500; const collideRadius = 15 + (mappedValue * 25); // Créer la simulation D3 const simulation = forceSimulation(simulationData) .force("charge", forceManyBody().strength(forceStrength)) .force("collide", forceCollide(collideRadius)) .stop(); // Exécuter la simulation for (let i = 0; i < simulationSteps; i++) { simulation.tick(); } // Calculer la bounding box après simulation const finalBBox = calculateBoundingBox(simulationData); // Calculer le facteur de réduction const widthRatio = initialBBox.width / finalBBox.width; const heightRatio = initialBBox.height / finalBBox.height; const reductionFactor = Math.min(widthRatio, heightRatio); // Appliquer la réduction const reducedPositions = simulationData.map(pos => ({ x: initialBBox.centerX + (pos.x - finalBBox.centerX) * reductionFactor, y: initialBBox.centerY + (pos.y - finalBBox.centerY) * reductionFactor })); // Retourner les positions mises à jour return reducedPositions.map((simPos, index) => ({ ...positions[index], x: simPos.x, y: simPos.y })); }; /** * Interpole entre deux ensembles de positions * @param {Array} positionsA - Positions A * @param {Array} positionsB - Positions B * @param {number} factor - Facteur d'interpolation (0-1) * @returns {Array} Positions interpolées */ const interpolatePositions = (positionsA, positionsB, factor) => { return positionsA.map((posA, index) => { const posB = positionsB[index]; if (!posB || posA.name !== posB.name) return posA; return { ...posA, x: posA.x + (posB.x - posA.x) * factor, y: posA.y + (posB.y - posA.y) * factor }; }); }; /** * Vide le cache des simulations (utile quand les données changent) */ export const clearSimulationCache = () => { simulationCache.clear(); lastPositionsHash = null; console.log('🧹 Simulation cache cleared'); }; /** * Obtient les statistiques du cache * @returns {Object} Statistiques du cache */ export const getCacheStats = () => { return { cacheSize: simulationCache.size, lastPositionsHash: lastPositionsHash ? lastPositionsHash.substring(0, 20) + '...' : null, cacheKeys: Array.from(simulationCache.keys()) }; }; /** * Calcule les positions avec simulation simple * @param {Array} fonts - Données des polices avec coordonnées UMAP * @param {number} width - Largeur du conteneur * @param {number} height - Hauteur du conteneur * @param {number} sliderValue - Valeur du slider (0-1) * @returns {Array} Positions calculées pour l'affichage */ export const calculatePositions = (fonts, width, height, sliderValue) => { if (!fonts || fonts.length === 0) { return []; } // Créer les échelles pour convertir les coordonnées UMAP en coordonnées d'écran const xExtent = d3.extent(fonts, d => d.x); const yExtent = d3.extent(fonts, d => d.y); const xScale = d3.scaleLinear() .domain(xExtent) .range([50, width - 50]); const yScale = d3.scaleLinear() .domain(yExtent) .range([height - 50, 50]); // Convertir les positions UMAP en coordonnées d'écran (positions de base) const basePositions = fonts.map(font => ({ ...font, originalX: font.x, originalY: font.y, x: xScale(font.x), y: yScale(font.y) })); // Si slider à 0, retourner les positions de base if (sliderValue === 0) { return basePositions; } // Appliquer la dilatation optimisée return applySimpleDilation(basePositions, sliderValue); };