Spaces:
Configuration error
Configuration error
feat: FontCLIP pipeline, category colors, and updated How It Works
Browse files- Replace pixel-based embeddings with FontCLIP (CLIP ViT-B/32 fine-tuned for typography)
- Add PCA + font family fusion + spectral UMAP pipeline (1877 → 1192 fonts)
- Add category color toggle with centroid labels on the map
- Fix DebugUMAP config persistence and Leva slider sync issues
- Update How It Works modal to reflect the new FontCLIP pipeline
- Clean up DebugUMAP store (remove conflicting localStorage, bump persist version)
Co-authored-by: Cursor <cursoragent@cursor.com>
- public/data/typography_data.json +0 -0
- public/debug-umap/fontclip-spectral_2026-02-18T15-31-31.json +0 -0
- public/debug-umap/index.json +24 -2
- src/components/DebugUMAP/DebugUMAP.js +1 -24
- src/components/DebugUMAP/components/LiveUMAPPanel.js +23 -20
- src/components/DebugUMAP/hooks/index.js +0 -1
- src/components/DebugUMAP/hooks/useKeyboardNavigation.js +1 -3
- src/components/DebugUMAP/hooks/useLeva.js +53 -88
- src/components/DebugUMAP/store/useDebugUMAPStore.js +8 -56
- src/components/DebugUMAP/utils/umapCalculator.js +104 -6
- src/components/DebugUMAP/workers/umap.worker.js +9 -7
- src/components/FontMap/FontMap.js +88 -119
- src/components/FontMap/components/AboutModal.js +36 -27
- src/components/FontMap/components/ActiveFont.js +81 -103
- src/components/FontMap/components/TooltipManager.js +2 -4
- src/components/FontMap/components/controls/CategoryLegend.js +22 -0
- src/components/FontMap/hooks/useMapRenderer.js +383 -0
- src/components/FontMap/hooks/useMapZoom.js +128 -0
- src/components/FontMap/hooks/useTooltipOptimized.js +12 -16
- src/components/FontMap/styles/about-modal.css +75 -0
- src/components/FontMap/styles/intro-modal.css +0 -1
- src/components/FontMap/styles/layout.css +92 -62
- src/hooks/useStaticFontData.js +14 -12
- src/store/fontMapStore.js +10 -0
- src/typography/new-pipe/2-generate-svgs.mjs +107 -5
- src/typography/new-pipe/3-generate-pngs.mjs +25 -21
- src/typography/new-pipe/4-generate-embeddings.mjs +9 -4
- src/typography/new-pipe/5-batch-umap.mjs +121 -16
- src/typography/new-pipe/8-deploy-to-prod.mjs +6 -2
- src/typography/new-pipe/python-pipeline/run_fontclip.py +348 -0
public/data/typography_data.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/debug-umap/fontclip-spectral_2026-02-18T15-31-31.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/debug-umap/index.json
CHANGED
|
@@ -1,7 +1,29 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_at": "
|
| 3 |
-
"total_configs":
|
| 4 |
"configs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
{
|
| 6 |
"filename": "balanced-compact_2025-10-26T19-48-41.json",
|
| 7 |
"testName": "balanced-compact",
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_at": "2026-02-18T16:00:00.000Z",
|
| 3 |
+
"total_configs": 8,
|
| 4 |
"configs": [
|
| 5 |
+
{
|
| 6 |
+
"filename": "fontclip-spectral_2026-02-18T15-31-31.json",
|
| 7 |
+
"testName": "fontclip-spectral",
|
| 8 |
+
"config": {
|
| 9 |
+
"nNeighbors": 15,
|
| 10 |
+
"minDist": 0.5,
|
| 11 |
+
"metric": "cosine",
|
| 12 |
+
"enableFontFusion": false,
|
| 13 |
+
"testName": "fontclip-spectral",
|
| 14 |
+
"randomSeed": 42
|
| 15 |
+
},
|
| 16 |
+
"metadata": {
|
| 17 |
+
"generated_at": "2026-02-18T15:31:31.000Z",
|
| 18 |
+
"total_fonts": 1877,
|
| 19 |
+
"method": "umap_from_fontclip_python",
|
| 20 |
+
"model": "FontCLIP ViT-B/32 (fine-tuned for typography)",
|
| 21 |
+
"pca_components": 50,
|
| 22 |
+
"umap_init": "spectral",
|
| 23 |
+
"note": "FontCLIP embeddings with spectral UMAP and high-dim k-NN"
|
| 24 |
+
},
|
| 25 |
+
"timestamp": "2026-02-18T15:31:31.000Z"
|
| 26 |
+
},
|
| 27 |
{
|
| 28 |
"filename": "balanced-compact_2025-10-26T19-48-41.json",
|
| 29 |
"testName": "balanced-compact",
|
src/components/DebugUMAP/DebugUMAP.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
| 2 |
-
import { useLeva, useZoom, useGlyphRenderer, useConfigs, useKeyboardNavigation
|
| 3 |
import { ConfigDisplay, MapContainer, ErrorDisplay, LoadingDisplay, LiveUMAPPanel } from './components';
|
| 4 |
import { useDebugUMAPStore } from './store';
|
| 5 |
import './styles/index.css';
|
|
@@ -13,35 +13,12 @@ const DebugUMAP = () => {
|
|
| 13 |
const [svgReady, setSvgReady] = useState(false);
|
| 14 |
const [showLivePanel, setShowLivePanel] = useState(true);
|
| 15 |
|
| 16 |
-
// Hooks pour la gestion de l'état (maintenant via Zustand)
|
| 17 |
-
const configs = useDebugUMAPStore((state) => state.configs);
|
| 18 |
-
const currentConfig = useDebugUMAPStore((state) => state.getCurrentConfig());
|
| 19 |
-
const currentConfigIndex = useDebugUMAPStore((state) => state.currentConfigIndex);
|
| 20 |
-
const setCurrentConfigIndex = useDebugUMAPStore((state) => state.setCurrentConfigIndex);
|
| 21 |
const error = useDebugUMAPStore((state) => state.error);
|
| 22 |
const loading = useDebugUMAPStore((state) => state.loading);
|
| 23 |
-
const useCategoryColors = useDebugUMAPStore((state) => state.useCategoryColors);
|
| 24 |
-
const setUseCategoryColors = useDebugUMAPStore((state) => state.setUseCategoryColors);
|
| 25 |
const baseGlyphSize = useDebugUMAPStore((state) => state.baseGlyphSize);
|
| 26 |
-
const setBaseGlyphSize = useDebugUMAPStore((state) => state.setBaseGlyphSize);
|
| 27 |
const darkMode = useDebugUMAPStore((state) => state.darkMode);
|
| 28 |
-
const setDarkMode = useDebugUMAPStore((state) => state.setDarkMode);
|
| 29 |
-
const showCentroids = useDebugUMAPStore((state) => state.showCentroids);
|
| 30 |
-
const setShowCentroids = useDebugUMAPStore((state) => state.setShowCentroids);
|
| 31 |
const setLiveResult = useDebugUMAPStore((state) => state.setLiveResult);
|
| 32 |
|
| 33 |
-
console.log('DebugUMAP: Valeurs du store:', {
|
| 34 |
-
configs: configs.length,
|
| 35 |
-
loading,
|
| 36 |
-
error,
|
| 37 |
-
baseGlyphSize,
|
| 38 |
-
useCategoryColors,
|
| 39 |
-
darkMode
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
// Hook pour la persistance localStorage (en premier pour charger la config)
|
| 43 |
-
useLocalStorage();
|
| 44 |
-
|
| 45 |
// Hook pour charger les configurations
|
| 46 |
useConfigs();
|
| 47 |
|
|
|
|
| 1 |
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
| 2 |
+
import { useLeva, useZoom, useGlyphRenderer, useConfigs, useKeyboardNavigation } from './hooks';
|
| 3 |
import { ConfigDisplay, MapContainer, ErrorDisplay, LoadingDisplay, LiveUMAPPanel } from './components';
|
| 4 |
import { useDebugUMAPStore } from './store';
|
| 5 |
import './styles/index.css';
|
|
|
|
| 13 |
const [svgReady, setSvgReady] = useState(false);
|
| 14 |
const [showLivePanel, setShowLivePanel] = useState(true);
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const error = useDebugUMAPStore((state) => state.error);
|
| 17 |
const loading = useDebugUMAPStore((state) => state.loading);
|
|
|
|
|
|
|
| 18 |
const baseGlyphSize = useDebugUMAPStore((state) => state.baseGlyphSize);
|
|
|
|
| 19 |
const darkMode = useDebugUMAPStore((state) => state.darkMode);
|
|
|
|
|
|
|
|
|
|
| 20 |
const setLiveResult = useDebugUMAPStore((state) => state.setLiveResult);
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// Hook pour charger les configurations
|
| 23 |
useConfigs();
|
| 24 |
|
src/components/DebugUMAP/components/LiveUMAPPanel.js
CHANGED
|
@@ -6,24 +6,28 @@ import React, { useEffect, useRef, useState } from 'react';
|
|
| 6 |
import { useControls, button, folder } from 'leva';
|
| 7 |
import { useLiveUMAP } from '../hooks';
|
| 8 |
|
|
|
|
|
|
|
| 9 |
const LiveUMAPPanel = ({ onResult }) => {
|
| 10 |
const { calculate, isCalculating, progress, error, result: lastResult } = useLiveUMAP();
|
| 11 |
const timeoutRef = useRef(null);
|
|
|
|
| 12 |
|
| 13 |
-
// Ref pour accéder au dernier résultat dans le callback du bouton (évite les closures stale)
|
| 14 |
const lastResultRef = useRef(lastResult);
|
| 15 |
useEffect(() => {
|
| 16 |
lastResultRef.current = lastResult;
|
| 17 |
}, [lastResult]);
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
// Fonction d'export
|
| 27 |
const handleExport = () => {
|
| 28 |
const result = lastResultRef.current;
|
| 29 |
if (!result) {
|
|
@@ -45,29 +49,29 @@ const LiveUMAPPanel = ({ onResult }) => {
|
|
| 45 |
console.log('💾 Export lancé !');
|
| 46 |
};
|
| 47 |
|
| 48 |
-
//
|
| 49 |
const [, set] = useControls(() => ({
|
| 50 |
'Live UMAP 🧪': folder({
|
| 51 |
nNeighbors: {
|
| 52 |
-
value:
|
| 53 |
min: 5,
|
| 54 |
max: 50,
|
| 55 |
step: 1,
|
| 56 |
label: 'Neighbors',
|
| 57 |
-
onChange: (v) =>
|
| 58 |
},
|
| 59 |
minDist: {
|
| 60 |
-
value:
|
| 61 |
min: 0.1,
|
| 62 |
max: 2.0,
|
| 63 |
step: 0.1,
|
| 64 |
label: 'Min Dist',
|
| 65 |
-
onChange: (v) =>
|
| 66 |
},
|
| 67 |
enableFontFusion: {
|
| 68 |
-
value:
|
| 69 |
label: 'Fusion Families',
|
| 70 |
-
onChange: (v) =>
|
| 71 |
},
|
| 72 |
'Export JSON': button(() => handleExport()),
|
| 73 |
Status: {
|
|
@@ -78,7 +82,6 @@ const LiveUMAPPanel = ({ onResult }) => {
|
|
| 78 |
}, { collapsed: false })
|
| 79 |
}));
|
| 80 |
|
| 81 |
-
// Mettre à jour le statut via 'set' pour garantir le rafraîchissement
|
| 82 |
useEffect(() => {
|
| 83 |
const statusText = isCalculating
|
| 84 |
? `${progress?.stage || 'Calculating'}... ${progress?.progress || 0}%`
|
|
@@ -87,14 +90,14 @@ const LiveUMAPPanel = ({ onResult }) => {
|
|
| 87 |
set({ Status: statusText });
|
| 88 |
}, [isCalculating, progress, lastResult, set]);
|
| 89 |
|
| 90 |
-
//
|
| 91 |
useEffect(() => {
|
| 92 |
-
|
|
|
|
| 93 |
if (timeoutRef.current) {
|
| 94 |
clearTimeout(timeoutRef.current);
|
| 95 |
}
|
| 96 |
|
| 97 |
-
// Définir un nouveau timeout (debounce)
|
| 98 |
timeoutRef.current = setTimeout(async () => {
|
| 99 |
console.log('🔄 Triggering Live UMAP calculation...', config);
|
| 100 |
try {
|
|
@@ -111,7 +114,7 @@ const LiveUMAPPanel = ({ onResult }) => {
|
|
| 111 |
} catch (err) {
|
| 112 |
console.error('❌ Error in Live UMAP:', err);
|
| 113 |
}
|
| 114 |
-
}, 600);
|
| 115 |
|
| 116 |
return () => {
|
| 117 |
if (timeoutRef.current) {
|
|
|
|
| 6 |
import { useControls, button, folder } from 'leva';
|
| 7 |
import { useLiveUMAP } from '../hooks';
|
| 8 |
|
| 9 |
+
const DEFAULTS = { nNeighbors: 12, minDist: 1.0, enableFontFusion: true };
|
| 10 |
+
|
| 11 |
const LiveUMAPPanel = ({ onResult }) => {
|
| 12 |
const { calculate, isCalculating, progress, error, result: lastResult } = useLiveUMAP();
|
| 13 |
const timeoutRef = useRef(null);
|
| 14 |
+
const userChangedRef = useRef(false);
|
| 15 |
|
|
|
|
| 16 |
const lastResultRef = useRef(lastResult);
|
| 17 |
useEffect(() => {
|
| 18 |
lastResultRef.current = lastResult;
|
| 19 |
}, [lastResult]);
|
| 20 |
|
| 21 |
+
const [config, setConfig] = useState(DEFAULTS);
|
| 22 |
+
|
| 23 |
+
const handleUserChange = (key, v) => {
|
| 24 |
+
setConfig(prev => {
|
| 25 |
+
if (prev[key] === v) return prev;
|
| 26 |
+
userChangedRef.current = true;
|
| 27 |
+
return { ...prev, [key]: v };
|
| 28 |
+
});
|
| 29 |
+
};
|
| 30 |
|
|
|
|
| 31 |
const handleExport = () => {
|
| 32 |
const result = lastResultRef.current;
|
| 33 |
if (!result) {
|
|
|
|
| 49 |
console.log('💾 Export lancé !');
|
| 50 |
};
|
| 51 |
|
| 52 |
+
// eslint-disable-next-line no-unused-vars
|
| 53 |
const [, set] = useControls(() => ({
|
| 54 |
'Live UMAP 🧪': folder({
|
| 55 |
nNeighbors: {
|
| 56 |
+
value: DEFAULTS.nNeighbors,
|
| 57 |
min: 5,
|
| 58 |
max: 50,
|
| 59 |
step: 1,
|
| 60 |
label: 'Neighbors',
|
| 61 |
+
onChange: (v) => handleUserChange('nNeighbors', v)
|
| 62 |
},
|
| 63 |
minDist: {
|
| 64 |
+
value: DEFAULTS.minDist,
|
| 65 |
min: 0.1,
|
| 66 |
max: 2.0,
|
| 67 |
step: 0.1,
|
| 68 |
label: 'Min Dist',
|
| 69 |
+
onChange: (v) => handleUserChange('minDist', v)
|
| 70 |
},
|
| 71 |
enableFontFusion: {
|
| 72 |
+
value: DEFAULTS.enableFontFusion,
|
| 73 |
label: 'Fusion Families',
|
| 74 |
+
onChange: (v) => handleUserChange('enableFontFusion', v)
|
| 75 |
},
|
| 76 |
'Export JSON': button(() => handleExport()),
|
| 77 |
Status: {
|
|
|
|
| 82 |
}, { collapsed: false })
|
| 83 |
}));
|
| 84 |
|
|
|
|
| 85 |
useEffect(() => {
|
| 86 |
const statusText = isCalculating
|
| 87 |
? `${progress?.stage || 'Calculating'}... ${progress?.progress || 0}%`
|
|
|
|
| 90 |
set({ Status: statusText });
|
| 91 |
}, [isCalculating, progress, lastResult, set]);
|
| 92 |
|
| 93 |
+
// Only calculate when the user has actually interacted with the controls
|
| 94 |
useEffect(() => {
|
| 95 |
+
if (!userChangedRef.current) return;
|
| 96 |
+
|
| 97 |
if (timeoutRef.current) {
|
| 98 |
clearTimeout(timeoutRef.current);
|
| 99 |
}
|
| 100 |
|
|
|
|
| 101 |
timeoutRef.current = setTimeout(async () => {
|
| 102 |
console.log('🔄 Triggering Live UMAP calculation...', config);
|
| 103 |
try {
|
|
|
|
| 114 |
} catch (err) {
|
| 115 |
console.error('❌ Error in Live UMAP:', err);
|
| 116 |
}
|
| 117 |
+
}, 600);
|
| 118 |
|
| 119 |
return () => {
|
| 120 |
if (timeoutRef.current) {
|
src/components/DebugUMAP/hooks/index.js
CHANGED
|
@@ -5,7 +5,6 @@ export { useLeva } from './useLeva.js';
|
|
| 5 |
export { useZoom } from './useZoom.js';
|
| 6 |
export { useGlyphRenderer } from './useGlyphRenderer.js';
|
| 7 |
export { useKeyboardNavigation } from './useKeyboardNavigation.js';
|
| 8 |
-
export { useLocalStorage } from './useLocalStorage.js';
|
| 9 |
export { useLiveUMAP } from './useLiveUMAP.js';
|
| 10 |
|
| 11 |
// Re-export du store pour faciliter l'import
|
|
|
|
| 5 |
export { useZoom } from './useZoom.js';
|
| 6 |
export { useGlyphRenderer } from './useGlyphRenderer.js';
|
| 7 |
export { useKeyboardNavigation } from './useKeyboardNavigation.js';
|
|
|
|
| 8 |
export { useLiveUMAP } from './useLiveUMAP.js';
|
| 9 |
|
| 10 |
// Re-export du store pour faciliter l'import
|
src/components/DebugUMAP/hooks/useKeyboardNavigation.js
CHANGED
|
@@ -9,7 +9,6 @@ export function useKeyboardNavigation() {
|
|
| 9 |
const configs = useDebugUMAPStore((state) => state.configs);
|
| 10 |
const currentConfigIndex = useDebugUMAPStore((state) => state.currentConfigIndex);
|
| 11 |
const setCurrentConfigIndex = useDebugUMAPStore((state) => state.setCurrentConfigIndex);
|
| 12 |
-
const saveToLocalStorage = useDebugUMAPStore((state) => state.saveToLocalStorage);
|
| 13 |
|
| 14 |
useEffect(() => {
|
| 15 |
const handleKeyDown = (event) => {
|
|
@@ -54,7 +53,6 @@ export function useKeyboardNavigation() {
|
|
| 54 |
if (newIndex !== currentConfigIndex) {
|
| 55 |
console.log(`Navigation clavier: ${event.key} -> config ${newIndex}`);
|
| 56 |
setCurrentConfigIndex(newIndex);
|
| 57 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 58 |
}
|
| 59 |
};
|
| 60 |
|
|
@@ -65,7 +63,7 @@ export function useKeyboardNavigation() {
|
|
| 65 |
return () => {
|
| 66 |
document.removeEventListener('keydown', handleKeyDown);
|
| 67 |
};
|
| 68 |
-
}, [configs.length, currentConfigIndex, setCurrentConfigIndex
|
| 69 |
|
| 70 |
return {
|
| 71 |
currentConfigIndex,
|
|
|
|
| 9 |
const configs = useDebugUMAPStore((state) => state.configs);
|
| 10 |
const currentConfigIndex = useDebugUMAPStore((state) => state.currentConfigIndex);
|
| 11 |
const setCurrentConfigIndex = useDebugUMAPStore((state) => state.setCurrentConfigIndex);
|
|
|
|
| 12 |
|
| 13 |
useEffect(() => {
|
| 14 |
const handleKeyDown = (event) => {
|
|
|
|
| 53 |
if (newIndex !== currentConfigIndex) {
|
| 54 |
console.log(`Navigation clavier: ${event.key} -> config ${newIndex}`);
|
| 55 |
setCurrentConfigIndex(newIndex);
|
|
|
|
| 56 |
}
|
| 57 |
};
|
| 58 |
|
|
|
|
| 63 |
return () => {
|
| 64 |
document.removeEventListener('keydown', handleKeyDown);
|
| 65 |
};
|
| 66 |
+
}, [configs.length, currentConfigIndex, setCurrentConfigIndex]);
|
| 67 |
|
| 68 |
return {
|
| 69 |
currentConfigIndex,
|
src/components/DebugUMAP/hooks/useLeva.js
CHANGED
|
@@ -1,113 +1,78 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
-
import { useControls
|
| 3 |
import { useDebugUMAPStore } from '../store';
|
| 4 |
import { getConfig } from '../config/mapConfig.js';
|
| 5 |
|
|
|
|
|
|
|
| 6 |
/**
|
| 7 |
-
* Hook pour gérer l'interface Leva avec Zustand
|
|
|
|
| 8 |
*/
|
| 9 |
export function useLeva({ onResetZoom }) {
|
| 10 |
const configs = useDebugUMAPStore((state) => state.configs);
|
| 11 |
-
const currentConfigIndex = useDebugUMAPStore((state) => state.currentConfigIndex);
|
| 12 |
-
const setCurrentConfigIndex = useDebugUMAPStore((state) => state.setCurrentConfigIndex);
|
| 13 |
-
const useCategoryColors = useDebugUMAPStore((state) => state.useCategoryColors);
|
| 14 |
-
const setUseCategoryColors = useDebugUMAPStore((state) => state.setUseCategoryColors);
|
| 15 |
-
const baseGlyphSize = useDebugUMAPStore((state) => state.baseGlyphSize);
|
| 16 |
-
const setBaseGlyphSize = useDebugUMAPStore((state) => state.setBaseGlyphSize);
|
| 17 |
-
const darkMode = useDebugUMAPStore((state) => state.darkMode);
|
| 18 |
-
const setDarkMode = useDebugUMAPStore((state) => state.setDarkMode);
|
| 19 |
-
const showCentroids = useDebugUMAPStore((state) => state.showCentroids);
|
| 20 |
-
const setShowCentroids = useDebugUMAPStore((state) => state.setShowCentroids);
|
| 21 |
const resetToDefaults = useDebugUMAPStore((state) => state.resetToDefaults);
|
| 22 |
-
const saveToLocalStorage = useDebugUMAPStore((state) => state.saveToLocalStorage);
|
| 23 |
|
| 24 |
-
|
| 25 |
-
const store = useStoreContext();
|
| 26 |
|
| 27 |
-
//
|
| 28 |
-
const controls = useControls({
|
| 29 |
-
// Slider de configuration
|
| 30 |
Configuration: {
|
| 31 |
-
value: currentConfigIndex,
|
| 32 |
min: 0,
|
| 33 |
-
max:
|
| 34 |
-
step: 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
},
|
| 36 |
-
|
| 37 |
-
// Toggle pour les couleurs
|
| 38 |
-
'Category Colors': useCategoryColors, // Utiliser la valeur du store
|
| 39 |
-
|
| 40 |
-
// Slider pour la taille des glyphes
|
| 41 |
-
'Glyph Size': {
|
| 42 |
-
value: baseGlyphSize, // Utiliser la valeur du store
|
| 43 |
-
min: getConfig('glyph.size.min', 0.05),
|
| 44 |
-
max: getConfig('glyph.size.max', 1.0),
|
| 45 |
-
step: getConfig('glyph.size.step', 0.05)
|
| 46 |
-
},
|
| 47 |
-
|
| 48 |
-
// Toggle pour le dark mode
|
| 49 |
-
'Dark Mode': darkMode, // Utiliser la valeur du store
|
| 50 |
-
|
| 51 |
-
// Toggle pour les centroïdes
|
| 52 |
-
'Show Centroids': showCentroids, // Utiliser la valeur du store
|
| 53 |
-
|
| 54 |
-
// Bouton pour reset zoom
|
| 55 |
'Reset Zoom': { button: true },
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
'Reset Defaults': { button: true }
|
| 59 |
-
});
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
if (controls.Configuration !== undefined) {
|
| 64 |
-
console.log('useLeva: Configuration changée vers', controls.Configuration);
|
| 65 |
-
setCurrentConfigIndex(controls.Configuration);
|
| 66 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 67 |
-
}
|
| 68 |
-
}, [controls.Configuration, setCurrentConfigIndex, saveToLocalStorage]);
|
| 69 |
|
| 70 |
React.useEffect(() => {
|
| 71 |
-
if (
|
| 72 |
-
|
| 73 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 74 |
-
}
|
| 75 |
-
}, [controls['Category Colors'], setUseCategoryColors, saveToLocalStorage]);
|
| 76 |
-
|
| 77 |
-
React.useEffect(() => {
|
| 78 |
-
if (controls['Glyph Size'] !== undefined) {
|
| 79 |
-
setBaseGlyphSize(controls['Glyph Size']);
|
| 80 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 81 |
-
}
|
| 82 |
-
}, [controls['Glyph Size'], setBaseGlyphSize, saveToLocalStorage]);
|
| 83 |
|
| 84 |
React.useEffect(() => {
|
| 85 |
-
if (
|
| 86 |
-
setDarkMode(controls['Dark Mode']);
|
| 87 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 88 |
-
}
|
| 89 |
-
}, [controls['Dark Mode'], setDarkMode, saveToLocalStorage]);
|
| 90 |
-
|
| 91 |
-
React.useEffect(() => {
|
| 92 |
-
if (controls['Show Centroids'] !== undefined) {
|
| 93 |
-
setShowCentroids(controls['Show Centroids']);
|
| 94 |
-
saveToLocalStorage(); // Sauvegarder automatiquement
|
| 95 |
-
}
|
| 96 |
-
}, [controls['Show Centroids'], setShowCentroids, saveToLocalStorage]);
|
| 97 |
-
|
| 98 |
-
React.useEffect(() => {
|
| 99 |
-
if (controls['Reset Zoom']) {
|
| 100 |
-
onResetZoom();
|
| 101 |
-
}
|
| 102 |
-
}, [controls['Reset Zoom'], onResetZoom]);
|
| 103 |
-
|
| 104 |
-
React.useEffect(() => {
|
| 105 |
-
if (controls['Reset Defaults']) {
|
| 106 |
resetToDefaults();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
-
}, [
|
| 109 |
-
|
| 110 |
-
// Plus besoin de synchronisation externe car Leva s'initialise avec les bonnes valeurs
|
| 111 |
|
| 112 |
return {};
|
| 113 |
}
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { useControls } from 'leva';
|
| 3 |
import { useDebugUMAPStore } from '../store';
|
| 4 |
import { getConfig } from '../config/mapConfig.js';
|
| 5 |
|
| 6 |
+
const getStore = () => useDebugUMAPStore.getState();
|
| 7 |
+
|
| 8 |
/**
|
| 9 |
+
* Hook pour gérer l'interface Leva avec Zustand.
|
| 10 |
+
* All onChange callbacks read the store via getState() to avoid stale closures.
|
| 11 |
*/
|
| 12 |
export function useLeva({ onResetZoom }) {
|
| 13 |
const configs = useDebugUMAPStore((state) => state.configs);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const resetToDefaults = useDebugUMAPStore((state) => state.resetToDefaults);
|
|
|
|
| 15 |
|
| 16 |
+
const maxConfig = Math.max(0, configs.length - 1);
|
|
|
|
| 17 |
|
| 18 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 19 |
+
const [controls, setLeva] = useControls(() => ({
|
|
|
|
| 20 |
Configuration: {
|
| 21 |
+
value: Math.min(getStore().currentConfigIndex, maxConfig),
|
| 22 |
min: 0,
|
| 23 |
+
max: maxConfig,
|
| 24 |
+
step: 1,
|
| 25 |
+
onChange: (v) => {
|
| 26 |
+
const s = getStore();
|
| 27 |
+
const upperBound = s.configs.length - 1;
|
| 28 |
+
const clamped = Math.max(0, Math.min(v, upperBound));
|
| 29 |
+
s.setCurrentConfigIndex(clamped);
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
'Category Colors': {
|
| 33 |
+
value: getStore().useCategoryColors,
|
| 34 |
+
onChange: (v) => { getStore().setUseCategoryColors(v); },
|
| 35 |
+
},
|
| 36 |
+
'Glyph Size': {
|
| 37 |
+
value: getStore().baseGlyphSize,
|
| 38 |
+
min: getConfig('glyph.size.min', 0.05),
|
| 39 |
+
max: getConfig('glyph.size.max', 1.0),
|
| 40 |
+
step: getConfig('glyph.size.step', 0.05),
|
| 41 |
+
onChange: (v) => { getStore().setBaseGlyphSize(v); },
|
| 42 |
+
},
|
| 43 |
+
'Dark Mode': {
|
| 44 |
+
value: getStore().darkMode,
|
| 45 |
+
onChange: (v) => { getStore().setDarkMode(v); },
|
| 46 |
+
},
|
| 47 |
+
'Show Centroids': {
|
| 48 |
+
value: getStore().showCentroids,
|
| 49 |
+
onChange: (v) => { getStore().setShowCentroids(v); },
|
| 50 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
'Reset Zoom': { button: true },
|
| 52 |
+
'Reset Defaults': { button: true },
|
| 53 |
+
}), [maxConfig]);
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
const resetZoomFlag = controls['Reset Zoom'];
|
| 56 |
+
const resetDefaultsFlag = controls['Reset Defaults'];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
React.useEffect(() => {
|
| 59 |
+
if (resetZoomFlag) onResetZoom();
|
| 60 |
+
}, [resetZoomFlag, onResetZoom]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
React.useEffect(() => {
|
| 63 |
+
if (resetDefaultsFlag) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
resetToDefaults();
|
| 65 |
+
if (setLeva) {
|
| 66 |
+
setLeva({
|
| 67 |
+
Configuration: 0,
|
| 68 |
+
'Category Colors': true,
|
| 69 |
+
'Glyph Size': 0.25,
|
| 70 |
+
'Dark Mode': false,
|
| 71 |
+
'Show Centroids': true,
|
| 72 |
+
});
|
| 73 |
+
}
|
| 74 |
}
|
| 75 |
+
}, [resetDefaultsFlag, resetToDefaults, setLeva]);
|
|
|
|
|
|
|
| 76 |
|
| 77 |
return {};
|
| 78 |
}
|
src/components/DebugUMAP/store/useDebugUMAPStore.js
CHANGED
|
@@ -66,59 +66,6 @@ export const useDebugUMAPStore = create(
|
|
| 66 |
error: null
|
| 67 |
}),
|
| 68 |
|
| 69 |
-
// === Actions de persistance ===
|
| 70 |
-
saveToLocalStorage: () => {
|
| 71 |
-
try {
|
| 72 |
-
const state = get();
|
| 73 |
-
const persistData = {
|
| 74 |
-
currentConfigIndex: state.currentConfigIndex,
|
| 75 |
-
useCategoryColors: state.useCategoryColors,
|
| 76 |
-
baseGlyphSize: state.baseGlyphSize,
|
| 77 |
-
darkMode: state.darkMode,
|
| 78 |
-
showCentroids: state.showCentroids,
|
| 79 |
-
timestamp: Date.now()
|
| 80 |
-
};
|
| 81 |
-
|
| 82 |
-
console.log('useDebugUMAPStore: Sauvegarde de la configuration:', persistData);
|
| 83 |
-
localStorage.setItem('debug-umap-config', JSON.stringify(persistData));
|
| 84 |
-
console.log('useDebugUMAPStore: ✅ Configuration sauvegardée avec succès');
|
| 85 |
-
} catch (error) {
|
| 86 |
-
console.error('useDebugUMAPStore: ❌ Erreur lors de la sauvegarde:', error);
|
| 87 |
-
}
|
| 88 |
-
},
|
| 89 |
-
|
| 90 |
-
loadFromLocalStorage: () => {
|
| 91 |
-
try {
|
| 92 |
-
console.log('useDebugUMAPStore: Recherche de la configuration dans localStorage...');
|
| 93 |
-
const saved = localStorage.getItem('debug-umap-config');
|
| 94 |
-
|
| 95 |
-
if (saved) {
|
| 96 |
-
console.log('useDebugUMAPStore: Données trouvées:', saved);
|
| 97 |
-
const persistData = JSON.parse(saved);
|
| 98 |
-
console.log('useDebugUMAPStore: Configuration parsée:', persistData);
|
| 99 |
-
|
| 100 |
-
// Appliquer les données sauvegardées
|
| 101 |
-
const newState = {
|
| 102 |
-
currentConfigIndex: persistData.currentConfigIndex || 0,
|
| 103 |
-
useCategoryColors: persistData.useCategoryColors !== undefined ? persistData.useCategoryColors : true,
|
| 104 |
-
baseGlyphSize: persistData.baseGlyphSize || 0.25,
|
| 105 |
-
darkMode: persistData.darkMode !== undefined ? persistData.darkMode : false,
|
| 106 |
-
showCentroids: persistData.showCentroids !== undefined ? persistData.showCentroids : true,
|
| 107 |
-
};
|
| 108 |
-
|
| 109 |
-
console.log('useDebugUMAPStore: Application de la configuration:', newState);
|
| 110 |
-
set(newState);
|
| 111 |
-
|
| 112 |
-
return true;
|
| 113 |
-
} else {
|
| 114 |
-
console.log('useDebugUMAPStore: Aucune donnée trouvée dans localStorage');
|
| 115 |
-
}
|
| 116 |
-
} catch (error) {
|
| 117 |
-
console.error('useDebugUMAPStore: Erreur lors du chargement:', error);
|
| 118 |
-
}
|
| 119 |
-
return false;
|
| 120 |
-
},
|
| 121 |
-
|
| 122 |
// === Getters utiles ===
|
| 123 |
getCurrentConfig: () => {
|
| 124 |
const { configs, currentConfigIndex } = get();
|
|
@@ -131,15 +78,20 @@ export const useDebugUMAPStore = create(
|
|
| 131 |
}
|
| 132 |
}),
|
| 133 |
{
|
| 134 |
-
name: 'debug-umap-persist',
|
| 135 |
-
|
| 136 |
partialize: (state) => ({
|
| 137 |
-
currentConfigIndex: state.currentConfigIndex,
|
| 138 |
useCategoryColors: state.useCategoryColors,
|
| 139 |
baseGlyphSize: state.baseGlyphSize,
|
| 140 |
darkMode: state.darkMode,
|
| 141 |
showCentroids: state.showCentroids,
|
| 142 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}
|
| 144 |
),
|
| 145 |
{
|
|
|
|
| 66 |
error: null
|
| 67 |
}),
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
// === Getters utiles ===
|
| 70 |
getCurrentConfig: () => {
|
| 71 |
const { configs, currentConfigIndex } = get();
|
|
|
|
| 78 |
}
|
| 79 |
}),
|
| 80 |
{
|
| 81 |
+
name: 'debug-umap-persist',
|
| 82 |
+
version: 2,
|
| 83 |
partialize: (state) => ({
|
|
|
|
| 84 |
useCategoryColors: state.useCategoryColors,
|
| 85 |
baseGlyphSize: state.baseGlyphSize,
|
| 86 |
darkMode: state.darkMode,
|
| 87 |
showCentroids: state.showCentroids,
|
| 88 |
}),
|
| 89 |
+
migrate: (persisted) => ({
|
| 90 |
+
useCategoryColors: persisted?.useCategoryColors ?? true,
|
| 91 |
+
baseGlyphSize: persisted?.baseGlyphSize ?? 0.25,
|
| 92 |
+
darkMode: persisted?.darkMode ?? false,
|
| 93 |
+
showCentroids: persisted?.showCentroids ?? true,
|
| 94 |
+
}),
|
| 95 |
}
|
| 96 |
),
|
| 97 |
{
|
src/components/DebugUMAP/utils/umapCalculator.js
CHANGED
|
@@ -148,17 +148,15 @@ export function mergeFontFamilies(fontDataList, embeddingMatrices, enableFusion
|
|
| 148 |
}
|
| 149 |
|
| 150 |
/**
|
| 151 |
-
* Normalise les données (standardisation)
|
| 152 |
*/
|
| 153 |
export function normalizeData(data) {
|
| 154 |
const rows = data.length;
|
| 155 |
const cols = data[0].length;
|
| 156 |
|
| 157 |
-
// Calculer moyennes et écarts types par colonne
|
| 158 |
const means = new Array(cols).fill(0);
|
| 159 |
const stds = new Array(cols).fill(0);
|
| 160 |
|
| 161 |
-
// Moyennes
|
| 162 |
for (let i = 0; i < rows; i++) {
|
| 163 |
for (let j = 0; j < cols; j++) {
|
| 164 |
means[j] += data[i][j];
|
|
@@ -168,7 +166,6 @@ export function normalizeData(data) {
|
|
| 168 |
means[j] /= rows;
|
| 169 |
}
|
| 170 |
|
| 171 |
-
// Écarts types
|
| 172 |
for (let i = 0; i < rows; i++) {
|
| 173 |
for (let j = 0; j < cols; j++) {
|
| 174 |
const diff = data[i][j] - means[j];
|
|
@@ -177,13 +174,114 @@ export function normalizeData(data) {
|
|
| 177 |
}
|
| 178 |
for (let j = 0; j < cols; j++) {
|
| 179 |
stds[j] = Math.sqrt(stds[j] / rows);
|
| 180 |
-
if (stds[j] === 0) stds[j] = 1;
|
| 181 |
}
|
| 182 |
|
| 183 |
-
// Normaliser
|
| 184 |
const normalized = data.map(row =>
|
| 185 |
row.map((val, j) => (val - means[j]) / stds[j])
|
| 186 |
);
|
| 187 |
|
| 188 |
return normalized;
|
| 189 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
/**
|
| 151 |
+
* Normalise les données (standardisation Z-score)
|
| 152 |
*/
|
| 153 |
export function normalizeData(data) {
|
| 154 |
const rows = data.length;
|
| 155 |
const cols = data[0].length;
|
| 156 |
|
|
|
|
| 157 |
const means = new Array(cols).fill(0);
|
| 158 |
const stds = new Array(cols).fill(0);
|
| 159 |
|
|
|
|
| 160 |
for (let i = 0; i < rows; i++) {
|
| 161 |
for (let j = 0; j < cols; j++) {
|
| 162 |
means[j] += data[i][j];
|
|
|
|
| 166 |
means[j] /= rows;
|
| 167 |
}
|
| 168 |
|
|
|
|
| 169 |
for (let i = 0; i < rows; i++) {
|
| 170 |
for (let j = 0; j < cols; j++) {
|
| 171 |
const diff = data[i][j] - means[j];
|
|
|
|
| 174 |
}
|
| 175 |
for (let j = 0; j < cols; j++) {
|
| 176 |
stds[j] = Math.sqrt(stds[j] / rows);
|
| 177 |
+
if (stds[j] === 0) stds[j] = 1;
|
| 178 |
}
|
| 179 |
|
|
|
|
| 180 |
const normalized = data.map(row =>
|
| 181 |
row.map((val, j) => (val - means[j]) / stds[j])
|
| 182 |
);
|
| 183 |
|
| 184 |
return normalized;
|
| 185 |
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* PCA via covariance eigen-decomposition (browser-friendly, no ml-matrix dependency).
|
| 189 |
+
* Reduces nDims → nComponents, concentrating variance for better UMAP quality.
|
| 190 |
+
*/
|
| 191 |
+
export function applyPCA(data, nComponents = 50) {
|
| 192 |
+
const rows = data.length;
|
| 193 |
+
const cols = data[0].length;
|
| 194 |
+
const target = Math.min(nComponents, cols, rows);
|
| 195 |
+
|
| 196 |
+
// Center columns
|
| 197 |
+
const means = new Array(cols).fill(0);
|
| 198 |
+
for (let i = 0; i < rows; i++) {
|
| 199 |
+
for (let j = 0; j < cols; j++) means[j] += data[i][j];
|
| 200 |
+
}
|
| 201 |
+
for (let j = 0; j < cols; j++) means[j] /= rows;
|
| 202 |
+
|
| 203 |
+
const centered = data.map(row => row.map((v, j) => v - means[j]));
|
| 204 |
+
|
| 205 |
+
// For browser perf: use SVD-like approach via X^T * X when cols > rows
|
| 206 |
+
// When rows < cols (typical: ~800 fonts, 512 dims), compute rows×rows gram matrix
|
| 207 |
+
if (rows < cols) {
|
| 208 |
+
// Gram matrix: X * X^T (rows × rows)
|
| 209 |
+
const gram = Array.from({ length: rows }, () => new Float64Array(rows));
|
| 210 |
+
for (let i = 0; i < rows; i++) {
|
| 211 |
+
for (let j = i; j < rows; j++) {
|
| 212 |
+
let dot = 0;
|
| 213 |
+
for (let k = 0; k < cols; k++) dot += centered[i][k] * centered[j][k];
|
| 214 |
+
gram[i][j] = dot / (rows - 1);
|
| 215 |
+
gram[j][i] = gram[i][j];
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// Power iteration for top eigenvectors of gram matrix
|
| 220 |
+
const eigenvectors = [];
|
| 221 |
+
const eigenvalues = [];
|
| 222 |
+
const gramCopy = gram.map(row => Float64Array.from(row));
|
| 223 |
+
|
| 224 |
+
for (let comp = 0; comp < target; comp++) {
|
| 225 |
+
let vec = new Float64Array(rows);
|
| 226 |
+
for (let i = 0; i < rows; i++) vec[i] = Math.random() - 0.5;
|
| 227 |
+
|
| 228 |
+
for (let iter = 0; iter < 100; iter++) {
|
| 229 |
+
const newVec = new Float64Array(rows);
|
| 230 |
+
for (let i = 0; i < rows; i++) {
|
| 231 |
+
let sum = 0;
|
| 232 |
+
for (let j = 0; j < rows; j++) sum += gramCopy[i][j] * vec[j];
|
| 233 |
+
newVec[i] = sum;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
let norm = 0;
|
| 237 |
+
for (let i = 0; i < rows; i++) norm += newVec[i] * newVec[i];
|
| 238 |
+
norm = Math.sqrt(norm);
|
| 239 |
+
if (norm === 0) break;
|
| 240 |
+
for (let i = 0; i < rows; i++) newVec[i] /= norm;
|
| 241 |
+
|
| 242 |
+
let diff = 0;
|
| 243 |
+
for (let i = 0; i < rows; i++) diff += (newVec[i] - vec[i]) ** 2;
|
| 244 |
+
vec = newVec;
|
| 245 |
+
if (diff < 1e-10) break;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
let eigenvalue = 0;
|
| 249 |
+
const Av = new Float64Array(rows);
|
| 250 |
+
for (let i = 0; i < rows; i++) {
|
| 251 |
+
let sum = 0;
|
| 252 |
+
for (let j = 0; j < rows; j++) sum += gramCopy[i][j] * vec[j];
|
| 253 |
+
Av[i] = sum;
|
| 254 |
+
}
|
| 255 |
+
for (let i = 0; i < rows; i++) eigenvalue += vec[i] * Av[i];
|
| 256 |
+
|
| 257 |
+
eigenvalues.push(eigenvalue);
|
| 258 |
+
eigenvectors.push(vec);
|
| 259 |
+
|
| 260 |
+
// Deflate
|
| 261 |
+
for (let i = 0; i < rows; i++) {
|
| 262 |
+
for (let j = 0; j < rows; j++) {
|
| 263 |
+
gramCopy[i][j] -= eigenvalue * vec[i] * vec[j];
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Project: each component = X^T * u_i / sqrt(lambda_i * (n-1))
|
| 269 |
+
const result = Array.from({ length: rows }, () => new Array(target));
|
| 270 |
+
for (let comp = 0; comp < target; comp++) {
|
| 271 |
+
for (let i = 0; i < rows; i++) {
|
| 272 |
+
result[i][comp] = eigenvectors[comp][i] * Math.sqrt(Math.max(0, eigenvalues[comp]) * (rows - 1));
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
const totalVar = eigenvalues.reduce((s, v) => s + Math.max(0, v), 0) || 1;
|
| 277 |
+
const explainedVar = eigenvalues.slice(0, target).reduce((s, v) => s + Math.max(0, v), 0);
|
| 278 |
+
console.log(`📐 PCA: ${cols}D → ${target}D (${(explainedVar / totalVar * 100).toFixed(1)}% variance)`);
|
| 279 |
+
|
| 280 |
+
return result;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Standard path when rows >= cols: covariance matrix cols × cols
|
| 284 |
+
// (fallback, unlikely for fonts dataset)
|
| 285 |
+
console.log(`📐 PCA: using standard covariance path (${cols}D → ${target}D)`);
|
| 286 |
+
return centered.map(row => row.slice(0, target));
|
| 287 |
+
}
|
src/components/DebugUMAP/workers/umap.worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/* eslint-disable no-restricted-globals */
|
| 2 |
import { UMAP } from 'umap-js';
|
| 3 |
-
import { loadEmbeddings, mergeFontFamilies, normalizeData } from '../utils/umapCalculator';
|
| 4 |
|
| 5 |
// Cache pour les embeddings
|
| 6 |
let cachedData = null;
|
|
@@ -59,11 +59,14 @@ async function calculateUMAP(config) {
|
|
| 59 |
const { fontDataList: mergedFonts, embeddingMatrices: mergedEmbeddings } =
|
| 60 |
mergeFontFamilies(fontDataList, embeddingMatrices, enableFontFusion);
|
| 61 |
|
| 62 |
-
// 4. Normalisation
|
| 63 |
-
self.postMessage({ type: 'PROGRESS', payload: { stage: 'normalizing', progress:
|
| 64 |
const normalizedData = normalizeData(mergedEmbeddings);
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
self.postMessage({ type: 'PROGRESS', payload: { stage: 'umap', progress: 60 } });
|
| 68 |
|
| 69 |
// Générateur aléatoire avec seed
|
|
@@ -99,11 +102,10 @@ async function calculateUMAP(config) {
|
|
| 99 |
if (canReuseKNN) {
|
| 100 |
console.log('♻️ Reusing cached KNN results');
|
| 101 |
umap.setPrecomputedKNN(cachedKNN.indices, cachedKNN.distances);
|
| 102 |
-
|
| 103 |
-
umap.initializeFit(normalizedData);
|
| 104 |
} else {
|
| 105 |
console.log('🆕 Calculating new KNN');
|
| 106 |
-
umap.initializeFit(
|
| 107 |
|
| 108 |
// Sauvegarder le KNN pour la prochaine fois
|
| 109 |
// Note: initializeFit ne retourne pas le KNN, on doit ruser ou espérer que umap-js expose l'état
|
|
|
|
| 1 |
/* eslint-disable no-restricted-globals */
|
| 2 |
import { UMAP } from 'umap-js';
|
| 3 |
+
import { loadEmbeddings, mergeFontFamilies, normalizeData, applyPCA } from '../utils/umapCalculator';
|
| 4 |
|
| 5 |
// Cache pour les embeddings
|
| 6 |
let cachedData = null;
|
|
|
|
| 59 |
const { fontDataList: mergedFonts, embeddingMatrices: mergedEmbeddings } =
|
| 60 |
mergeFontFamilies(fontDataList, embeddingMatrices, enableFontFusion);
|
| 61 |
|
| 62 |
+
// 4. Normalisation + PCA
|
| 63 |
+
self.postMessage({ type: 'PROGRESS', payload: { stage: 'normalizing', progress: 45 } });
|
| 64 |
const normalizedData = normalizeData(mergedEmbeddings);
|
| 65 |
|
| 66 |
+
self.postMessage({ type: 'PROGRESS', payload: { stage: 'pca', progress: 52 } });
|
| 67 |
+
const pcaData = applyPCA(normalizedData, 50);
|
| 68 |
+
|
| 69 |
+
// 5. UMAP (on PCA-reduced data)
|
| 70 |
self.postMessage({ type: 'PROGRESS', payload: { stage: 'umap', progress: 60 } });
|
| 71 |
|
| 72 |
// Générateur aléatoire avec seed
|
|
|
|
| 102 |
if (canReuseKNN) {
|
| 103 |
console.log('♻️ Reusing cached KNN results');
|
| 104 |
umap.setPrecomputedKNN(cachedKNN.indices, cachedKNN.distances);
|
| 105 |
+
umap.initializeFit(pcaData);
|
|
|
|
| 106 |
} else {
|
| 107 |
console.log('🆕 Calculating new KNN');
|
| 108 |
+
umap.initializeFit(pcaData);
|
| 109 |
|
| 110 |
// Sauvegarder le KNN pour la prochaine fois
|
| 111 |
// Note: initializeFit ne retourne pas le KNN, on doit ruser ou espérer que umap-js expose l'état
|
src/components/FontMap/FontMap.js
CHANGED
|
@@ -1,38 +1,40 @@
|
|
| 1 |
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
| 2 |
import '../FontMap.css';
|
| 3 |
|
| 4 |
-
// Import des hooks
|
| 5 |
import { useStaticFontData } from '../../hooks/useStaticFontData';
|
| 6 |
-
import {
|
|
|
|
| 7 |
import { useArrowNavigation } from './hooks/useArrowNavigation';
|
| 8 |
|
| 9 |
-
// Import des composants de contrôles
|
| 10 |
import FilterControls from './components/controls/FilterControls';
|
| 11 |
import SearchBar from './components/controls/SearchBar';
|
| 12 |
import ZoomControls from './components/controls/ZoomControls';
|
|
|
|
| 13 |
import ActiveFont from './components/ActiveFont';
|
| 14 |
import TooltipManager from './components/TooltipManager';
|
| 15 |
import IntroModal from './components/IntroModal';
|
| 16 |
import AboutModal from './components/AboutModal';
|
| 17 |
import FPSMonitor from './components/FPSMonitor';
|
| 18 |
|
| 19 |
-
// Import du hook Tweakpane
|
| 20 |
-
import { useTweakpane } from './hooks/useTweakpane';
|
| 21 |
import { useFontMapStore } from '../../store/fontMapStore';
|
| 22 |
import './styles/intro-modal.css';
|
| 23 |
import './styles/about-modal.css';
|
| 24 |
|
| 25 |
/**
|
| 26 |
-
* Composant principal FontMap
|
|
|
|
| 27 |
*/
|
| 28 |
const FontMap = ({ darkMode = false }) => {
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
const [filter, setFilter] = useState('all');
|
| 31 |
const [searchTerm, setSearchTerm] = useState('');
|
| 32 |
-
const [appState, setAppState] = useState('loading');
|
| 33 |
const [showAboutModal, setShowAboutModal] = useState(false);
|
| 34 |
|
| 35 |
-
// Récupérer seulement l'état nécessaire (les autres sont gérés dans les hooks)
|
| 36 |
const {
|
| 37 |
selectedFont,
|
| 38 |
hoveredFont,
|
|
@@ -40,30 +42,31 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 40 |
setHoveredFont
|
| 41 |
} = useFontMapStore();
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
};
|
| 46 |
-
|
| 47 |
-
const handleSearchChange = (newSearchTerm) => {
|
| 48 |
-
setSearchTerm(newSearchTerm);
|
| 49 |
-
};
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
};
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
};
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
| 67 |
|
| 68 |
const handleFontHover = useCallback((font) => {
|
| 69 |
setHoveredFont(font);
|
|
@@ -73,59 +76,26 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 73 |
setHoveredFont(null);
|
| 74 |
}, [setHoveredFont]);
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
useArrowNavigation(
|
| 85 |
-
selectedFont,
|
| 86 |
-
fonts,
|
| 87 |
-
filter,
|
| 88 |
-
searchTerm,
|
| 89 |
-
handleFontSelect
|
| 90 |
-
);
|
| 91 |
-
|
| 92 |
-
const svgRef = useD3Visualization(fonts, glyphPaths, filter, searchTerm, darkMode, loading);
|
| 93 |
-
|
| 94 |
-
// Initialiser Tweakpane
|
| 95 |
-
const { isDebugMode } = useTweakpane(darkMode);
|
| 96 |
-
|
| 97 |
-
// Calculer les compteurs pour la recherche
|
| 98 |
-
const totalFonts = fonts.length;
|
| 99 |
-
|
| 100 |
-
// Compter les polices correspondant au filtre actif (sans recherche)
|
| 101 |
-
const filterOnlyCount = filter === 'all' ? totalFonts : fonts.filter(font => font.family === filter).length;
|
| 102 |
-
|
| 103 |
-
// Compter les polices correspondant au filtre ET à la recherche
|
| 104 |
-
const filteredFonts = fonts.filter(font => {
|
| 105 |
-
const familyMatch = filter === 'all' || font.family === filter;
|
| 106 |
-
const searchMatch = !searchTerm || font.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 107 |
-
font.family.toLowerCase().includes(searchTerm.toLowerCase());
|
| 108 |
-
return familyMatch && searchMatch;
|
| 109 |
-
});
|
| 110 |
-
const filteredCount = filteredFonts.length;
|
| 111 |
|
| 112 |
-
//
|
| 113 |
useEffect(() => {
|
| 114 |
window.onFontHover = handleFontHover;
|
| 115 |
window.onFontUnhover = handleFontUnhover;
|
| 116 |
-
|
| 117 |
return () => {
|
| 118 |
delete window.onFontHover;
|
| 119 |
delete window.onFontUnhover;
|
| 120 |
};
|
| 121 |
}, [handleFontHover, handleFontUnhover]);
|
| 122 |
|
| 123 |
-
//
|
| 124 |
-
useEffect(() => {
|
| 125 |
-
console.log('FontMap: selectedFont state changed to', selectedFont);
|
| 126 |
-
}, [selectedFont]);
|
| 127 |
-
|
| 128 |
-
// Gérer les transitions d'état de l'application
|
| 129 |
useEffect(() => {
|
| 130 |
if (loading) {
|
| 131 |
setAppState('loading');
|
|
@@ -134,17 +104,43 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 134 |
}
|
| 135 |
}, [loading, fonts.length, appState]);
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
// Affichage de l'erreur
|
| 139 |
if (error) {
|
| 140 |
return (
|
| 141 |
<div className="fontmap-container">
|
| 142 |
<div className="error">
|
| 143 |
<h3>Erreur de chargement</h3>
|
| 144 |
<p>{error}</p>
|
| 145 |
-
<button onClick={() => window.location.reload()}>
|
| 146 |
-
Recharger la page
|
| 147 |
-
</button>
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
);
|
|
@@ -152,6 +148,9 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 152 |
|
| 153 |
return (
|
| 154 |
<div className={`fontmap-container ${darkMode ? 'dark-mode' : ''}`}>
|
|
|
|
|
|
|
|
|
|
| 155 |
{/* Sidebar */}
|
| 156 |
<div className="sidebar">
|
| 157 |
<div className="sidebar-content">
|
|
@@ -159,18 +158,17 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 159 |
<div className="search-section">
|
| 160 |
<SearchBar
|
| 161 |
searchTerm={searchTerm}
|
| 162 |
-
onSearchChange={
|
| 163 |
darkMode={darkMode}
|
| 164 |
big={true}
|
| 165 |
filteredCount={filteredCount}
|
| 166 |
totalCount={filterOnlyCount}
|
| 167 |
filter={filter}
|
| 168 |
/>
|
| 169 |
-
|
| 170 |
<FilterControls
|
| 171 |
fonts={fonts}
|
| 172 |
filter={filter}
|
| 173 |
-
onFilterChange={
|
| 174 |
/>
|
| 175 |
</div>
|
| 176 |
</div>
|
|
@@ -179,19 +177,13 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 179 |
selectedFont={selectedFont}
|
| 180 |
fonts={fonts}
|
| 181 |
darkMode={darkMode}
|
| 182 |
-
onClose={
|
| 183 |
onFontSelect={handleFontSelect}
|
| 184 |
/>
|
| 185 |
-
|
| 186 |
</div>
|
| 187 |
|
| 188 |
-
{/* Footer avec liens How it works et Source */}
|
| 189 |
<div className="sidebar-footer">
|
| 190 |
-
<button
|
| 191 |
-
className="about-link"
|
| 192 |
-
onClick={handleShowAbout}
|
| 193 |
-
title="How FontMap Works"
|
| 194 |
-
>
|
| 195 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 196 |
<circle cx="12" cy="12" r="10" />
|
| 197 |
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
@@ -199,14 +191,7 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 199 |
</svg>
|
| 200 |
How it works
|
| 201 |
</button>
|
| 202 |
-
|
| 203 |
-
<a
|
| 204 |
-
className="source-link"
|
| 205 |
-
href="https://huggingface.co/spaces/huggingface/fontmap"
|
| 206 |
-
target="_blank"
|
| 207 |
-
rel="noopener noreferrer"
|
| 208 |
-
title="View Source on Hugging Face Spaces"
|
| 209 |
-
>
|
| 210 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 211 |
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
| 212 |
</svg>
|
|
@@ -217,30 +202,25 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 217 |
|
| 218 |
{/* Zone principale */}
|
| 219 |
<div className="main-area">
|
| 220 |
-
{/* Titre FontMap en position absolue */}
|
| 221 |
<h1 className="map-title" data-text="FontMap">FontMap</h1>
|
| 222 |
|
| 223 |
-
{/* Contrôles du bas */}
|
| 224 |
<div className="bottom-controls">
|
|
|
|
| 225 |
<ZoomControls />
|
| 226 |
</div>
|
| 227 |
|
| 228 |
-
{/* Rendu de la carte */}
|
| 229 |
<div className="map-container">
|
| 230 |
<svg ref={svgRef} className="fontmap-svg"></svg>
|
| 231 |
{appState === 'loading' && (
|
| 232 |
<div className="map-loading-overlay">
|
| 233 |
<div className="map-loading-spinner">
|
| 234 |
<div className="spinner-large"></div>
|
| 235 |
-
<div className="loading-text">
|
| 236 |
-
Loading font map...
|
| 237 |
-
</div>
|
| 238 |
</div>
|
| 239 |
</div>
|
| 240 |
)}
|
| 241 |
</div>
|
| 242 |
|
| 243 |
-
{/* Gestionnaire de tooltips */}
|
| 244 |
{!loading && fonts.length > 0 && (
|
| 245 |
<TooltipManager
|
| 246 |
selectedFont={selectedFont}
|
|
@@ -252,33 +232,22 @@ const FontMap = ({ darkMode = false }) => {
|
|
| 252 |
)}
|
| 253 |
</div>
|
| 254 |
|
| 255 |
-
{/*
|
| 256 |
{appState === 'loading' && (
|
| 257 |
<div className="unified-overlay">
|
| 258 |
-
<div className="loading">
|
| 259 |
-
Initializing...
|
| 260 |
-
</div>
|
| 261 |
</div>
|
| 262 |
)}
|
| 263 |
|
| 264 |
-
{/* Modale d'introduction */}
|
| 265 |
{appState === 'intro' && (
|
| 266 |
-
<IntroModal
|
| 267 |
-
onStartExploring={handleStartExploring}
|
| 268 |
-
darkMode={darkMode}
|
| 269 |
-
/>
|
| 270 |
)}
|
| 271 |
|
| 272 |
-
{/* Modale About */}
|
| 273 |
{showAboutModal && (
|
| 274 |
-
<AboutModal
|
| 275 |
-
onClose={handleCloseAbout}
|
| 276 |
-
darkMode={darkMode}
|
| 277 |
-
/>
|
| 278 |
)}
|
| 279 |
|
| 280 |
-
{
|
| 281 |
-
<FPSMonitor isDebugMode={isDebugMode} />
|
| 282 |
</div>
|
| 283 |
);
|
| 284 |
};
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
+
import { useSearchParams } from 'react-router-dom';
|
| 3 |
import '../FontMap.css';
|
| 4 |
|
|
|
|
| 5 |
import { useStaticFontData } from '../../hooks/useStaticFontData';
|
| 6 |
+
import { useMapRenderer } from './hooks/useMapRenderer';
|
| 7 |
+
import { useMapZoom } from './hooks/useMapZoom';
|
| 8 |
import { useArrowNavigation } from './hooks/useArrowNavigation';
|
| 9 |
|
|
|
|
| 10 |
import FilterControls from './components/controls/FilterControls';
|
| 11 |
import SearchBar from './components/controls/SearchBar';
|
| 12 |
import ZoomControls from './components/controls/ZoomControls';
|
| 13 |
+
import CategoryLegend from './components/controls/CategoryLegend';
|
| 14 |
import ActiveFont from './components/ActiveFont';
|
| 15 |
import TooltipManager from './components/TooltipManager';
|
| 16 |
import IntroModal from './components/IntroModal';
|
| 17 |
import AboutModal from './components/AboutModal';
|
| 18 |
import FPSMonitor from './components/FPSMonitor';
|
| 19 |
|
|
|
|
|
|
|
| 20 |
import { useFontMapStore } from '../../store/fontMapStore';
|
| 21 |
import './styles/intro-modal.css';
|
| 22 |
import './styles/about-modal.css';
|
| 23 |
|
| 24 |
/**
|
| 25 |
+
* Composant principal FontMap — Moteur de rendu DebugUMAP + UI prod complète.
|
| 26 |
+
* Mode debug activable via ?debug=true dans l'URL.
|
| 27 |
*/
|
| 28 |
const FontMap = ({ darkMode = false }) => {
|
| 29 |
+
const svgRef = useRef(null);
|
| 30 |
+
const [searchParams] = useSearchParams();
|
| 31 |
+
const isDebugMode = searchParams.get('debug') === 'true';
|
| 32 |
+
|
| 33 |
const [filter, setFilter] = useState('all');
|
| 34 |
const [searchTerm, setSearchTerm] = useState('');
|
| 35 |
+
const [appState, setAppState] = useState('loading');
|
| 36 |
const [showAboutModal, setShowAboutModal] = useState(false);
|
| 37 |
|
|
|
|
| 38 |
const {
|
| 39 |
selectedFont,
|
| 40 |
hoveredFont,
|
|
|
|
| 42 |
setHoveredFont
|
| 43 |
} = useFontMapStore();
|
| 44 |
|
| 45 |
+
// ── Data : polices + chemins de glyphes (pour la sidebar ActiveFont) ──
|
| 46 |
+
const { fonts, glyphPaths, loading, error } = useStaticFontData();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
// ── Moteur de rendu DebugUMAP (viewBox + SVGs individuels + batch) ──
|
| 49 |
+
const svgReady = !loading && fonts.length > 0;
|
| 50 |
+
useMapRenderer({
|
| 51 |
+
svgRef,
|
| 52 |
+
fonts,
|
| 53 |
+
filter,
|
| 54 |
+
searchTerm,
|
| 55 |
+
darkMode,
|
| 56 |
+
loading,
|
| 57 |
+
enabled: svgReady
|
| 58 |
+
});
|
| 59 |
|
| 60 |
+
// ── Zoom simplifié (DebugUMAP-style) ──
|
| 61 |
+
const { centerOnFont, resetZoom } = useMapZoom(svgRef, svgReady);
|
|
|
|
| 62 |
|
| 63 |
+
// ── Navigation clavier ──
|
| 64 |
+
useArrowNavigation(selectedFont, fonts, filter, searchTerm, handleFontSelect);
|
|
|
|
| 65 |
|
| 66 |
+
// ── Callbacks ──
|
| 67 |
+
function handleFontSelect(font) {
|
| 68 |
+
setSelectedFont(font);
|
| 69 |
+
}
|
| 70 |
|
| 71 |
const handleFontHover = useCallback((font) => {
|
| 72 |
setHoveredFont(font);
|
|
|
|
| 76 |
setHoveredFont(null);
|
| 77 |
}, [setHoveredFont]);
|
| 78 |
|
| 79 |
+
// ── Centrage sur la police sélectionnée / reset au désélect ──
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
if (selectedFont) {
|
| 82 |
+
centerOnFont(selectedFont);
|
| 83 |
+
} else {
|
| 84 |
+
resetZoom();
|
| 85 |
+
}
|
| 86 |
+
}, [selectedFont, centerOnFont, resetZoom]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
// ── Callbacks globaux pour le TooltipManager ──
|
| 89 |
useEffect(() => {
|
| 90 |
window.onFontHover = handleFontHover;
|
| 91 |
window.onFontUnhover = handleFontUnhover;
|
|
|
|
| 92 |
return () => {
|
| 93 |
delete window.onFontHover;
|
| 94 |
delete window.onFontUnhover;
|
| 95 |
};
|
| 96 |
}, [handleFontHover, handleFontUnhover]);
|
| 97 |
|
| 98 |
+
// ── Transitions d'état ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
useEffect(() => {
|
| 100 |
if (loading) {
|
| 101 |
setAppState('loading');
|
|
|
|
| 104 |
}
|
| 105 |
}, [loading, fonts.length, appState]);
|
| 106 |
|
| 107 |
+
// ── Compteurs pour la recherche ──
|
| 108 |
+
const totalFonts = fonts.length;
|
| 109 |
+
const filterOnlyCount = filter === 'all' ? totalFonts : fonts.filter(f => f.family === filter).length;
|
| 110 |
+
|
| 111 |
+
const filteredFonts = useMemo(() => fonts.filter(font => {
|
| 112 |
+
const familyMatch = filter === 'all' || font.family === filter;
|
| 113 |
+
const searchMatch = !searchTerm ||
|
| 114 |
+
font.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 115 |
+
font.family.toLowerCase().includes(searchTerm.toLowerCase());
|
| 116 |
+
return familyMatch && searchMatch;
|
| 117 |
+
}), [fonts, filter, searchTerm]);
|
| 118 |
+
|
| 119 |
+
const filteredCount = filteredFonts.length;
|
| 120 |
+
|
| 121 |
+
// ── Symboles SVG cachés pour la sidebar (ActiveFont utilise <use>) ──
|
| 122 |
+
const symbolDefs = useMemo(() => {
|
| 123 |
+
if (!glyphPaths || Object.keys(glyphPaths).length === 0) return null;
|
| 124 |
+
return (
|
| 125 |
+
<svg style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }} aria-hidden="true">
|
| 126 |
+
<defs>
|
| 127 |
+
{Object.entries(glyphPaths).map(([id, pathData]) => (
|
| 128 |
+
<symbol key={id} id={id} viewBox="0 0 80 80">
|
| 129 |
+
<path d={pathData} fill="currentColor" />
|
| 130 |
+
</symbol>
|
| 131 |
+
))}
|
| 132 |
+
</defs>
|
| 133 |
+
</svg>
|
| 134 |
+
);
|
| 135 |
+
}, [glyphPaths]);
|
| 136 |
|
|
|
|
| 137 |
if (error) {
|
| 138 |
return (
|
| 139 |
<div className="fontmap-container">
|
| 140 |
<div className="error">
|
| 141 |
<h3>Erreur de chargement</h3>
|
| 142 |
<p>{error}</p>
|
| 143 |
+
<button onClick={() => window.location.reload()}>Recharger la page</button>
|
|
|
|
|
|
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
);
|
|
|
|
| 148 |
|
| 149 |
return (
|
| 150 |
<div className={`fontmap-container ${darkMode ? 'dark-mode' : ''}`}>
|
| 151 |
+
{/* Symboles SVG cachés pour la sidebar */}
|
| 152 |
+
{symbolDefs}
|
| 153 |
+
|
| 154 |
{/* Sidebar */}
|
| 155 |
<div className="sidebar">
|
| 156 |
<div className="sidebar-content">
|
|
|
|
| 158 |
<div className="search-section">
|
| 159 |
<SearchBar
|
| 160 |
searchTerm={searchTerm}
|
| 161 |
+
onSearchChange={setSearchTerm}
|
| 162 |
darkMode={darkMode}
|
| 163 |
big={true}
|
| 164 |
filteredCount={filteredCount}
|
| 165 |
totalCount={filterOnlyCount}
|
| 166 |
filter={filter}
|
| 167 |
/>
|
|
|
|
| 168 |
<FilterControls
|
| 169 |
fonts={fonts}
|
| 170 |
filter={filter}
|
| 171 |
+
onFilterChange={setFilter}
|
| 172 |
/>
|
| 173 |
</div>
|
| 174 |
</div>
|
|
|
|
| 177 |
selectedFont={selectedFont}
|
| 178 |
fonts={fonts}
|
| 179 |
darkMode={darkMode}
|
| 180 |
+
onClose={() => setSelectedFont(null)}
|
| 181 |
onFontSelect={handleFontSelect}
|
| 182 |
/>
|
|
|
|
| 183 |
</div>
|
| 184 |
|
|
|
|
| 185 |
<div className="sidebar-footer">
|
| 186 |
+
<button className="about-link" onClick={() => setShowAboutModal(true)} title="How FontMap Works">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 188 |
<circle cx="12" cy="12" r="10" />
|
| 189 |
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
|
|
| 191 |
</svg>
|
| 192 |
How it works
|
| 193 |
</button>
|
| 194 |
+
<a className="source-link" href="https://huggingface.co/spaces/huggingface/fontmap" target="_blank" rel="noopener noreferrer" title="View Source on Hugging Face Spaces">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 196 |
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
| 197 |
</svg>
|
|
|
|
| 202 |
|
| 203 |
{/* Zone principale */}
|
| 204 |
<div className="main-area">
|
|
|
|
| 205 |
<h1 className="map-title" data-text="FontMap">FontMap</h1>
|
| 206 |
|
|
|
|
| 207 |
<div className="bottom-controls">
|
| 208 |
+
<CategoryLegend darkMode={darkMode} />
|
| 209 |
<ZoomControls />
|
| 210 |
</div>
|
| 211 |
|
|
|
|
| 212 |
<div className="map-container">
|
| 213 |
<svg ref={svgRef} className="fontmap-svg"></svg>
|
| 214 |
{appState === 'loading' && (
|
| 215 |
<div className="map-loading-overlay">
|
| 216 |
<div className="map-loading-spinner">
|
| 217 |
<div className="spinner-large"></div>
|
| 218 |
+
<div className="loading-text">Loading font map...</div>
|
|
|
|
|
|
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
)}
|
| 222 |
</div>
|
| 223 |
|
|
|
|
| 224 |
{!loading && fonts.length > 0 && (
|
| 225 |
<TooltipManager
|
| 226 |
selectedFont={selectedFont}
|
|
|
|
| 232 |
)}
|
| 233 |
</div>
|
| 234 |
|
| 235 |
+
{/* Overlays */}
|
| 236 |
{appState === 'loading' && (
|
| 237 |
<div className="unified-overlay">
|
| 238 |
+
<div className="loading">Initializing...</div>
|
|
|
|
|
|
|
| 239 |
</div>
|
| 240 |
)}
|
| 241 |
|
|
|
|
| 242 |
{appState === 'intro' && (
|
| 243 |
+
<IntroModal onStartExploring={() => setAppState('ready')} darkMode={darkMode} />
|
|
|
|
|
|
|
|
|
|
| 244 |
)}
|
| 245 |
|
|
|
|
| 246 |
{showAboutModal && (
|
| 247 |
+
<AboutModal onClose={() => setShowAboutModal(false)} darkMode={darkMode} />
|
|
|
|
|
|
|
|
|
|
| 248 |
)}
|
| 249 |
|
| 250 |
+
{isDebugMode && <FPSMonitor isDebugMode={true} />}
|
|
|
|
| 251 |
</div>
|
| 252 |
);
|
| 253 |
};
|
src/components/FontMap/components/AboutModal.js
CHANGED
|
@@ -26,8 +26,8 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 26 |
|
| 27 |
<div className="about-modal-body">
|
| 28 |
<p className="about-description">
|
| 29 |
-
FontMap uses <strong>
|
| 30 |
-
|
| 31 |
</p>
|
| 32 |
|
| 33 |
<div className="about-content-grid">
|
|
@@ -41,18 +41,18 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 41 |
<div className="step-content">
|
| 42 |
<div className="step-header">
|
| 43 |
<span className="step-number">1</span>
|
| 44 |
-
<strong>
|
| 45 |
</div>
|
| 46 |
-
<p className="step-description">Each font is rendered as a <strong>
|
| 47 |
</div>
|
| 48 |
<div className="step-example">
|
| 49 |
-
<div className="
|
| 50 |
-
<div className="
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
<
|
| 55 |
-
<div className="
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
</div>
|
|
@@ -61,18 +61,18 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 61 |
<div className="step-content">
|
| 62 |
<div className="step-header">
|
| 63 |
<span className="step-number">2</span>
|
| 64 |
-
<strong>
|
| 65 |
</div>
|
| 66 |
-
<p className="step-description">
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
<div className="step-example">
|
| 69 |
<div className="mini-matrix">
|
| 70 |
-
<div className="mini-row"><
|
| 71 |
-
<div className="mini-row">
|
| 72 |
-
<div className="mini-row">
|
| 73 |
-
<div className="mini-row">
|
| 74 |
-
<div className="mini-row"><strong>0.1,0.1</strong>,<span className="zero-value">0.0</span>,<span className="zero-value">0.0</span>,<strong>0.1,0.1</strong></div>
|
| 75 |
-
<div className="mini-row"><strong>0.1,0.1</strong>,<span className="zero-value">0.0</span>,<span className="zero-value">0.0</span>,<strong>0.1,0.1</strong></div>
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
</div>
|
|
@@ -81,15 +81,24 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 81 |
<div className="step-content">
|
| 82 |
<div className="step-header">
|
| 83 |
<span className="step-number">3</span>
|
| 84 |
-
<strong>
|
| 85 |
</div>
|
| 86 |
<p className="step-description">
|
| 87 |
-
<strong>
|
| 88 |
<a href="https://pair-code.github.io/understanding-umap/" target="_blank" rel="noopener noreferrer" className="inline-link"> (Learn more about UMAP)</a>
|
| 89 |
</p>
|
| 90 |
</div>
|
| 91 |
<div className="step-example">
|
| 92 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
<div className="coord">x: -2.34</div>
|
| 94 |
<div className="coord">y: 1.67</div>
|
| 95 |
</div>
|
|
@@ -102,7 +111,7 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 102 |
<span className="step-number">4</span>
|
| 103 |
<strong>Interactive Visualization</strong>
|
| 104 |
</div>
|
| 105 |
-
<p className="step-description">Fonts are positioned on a <strong>2D map</strong> where proximity indicates <strong>visual similarity</strong></p>
|
| 106 |
</div>
|
| 107 |
<div className="step-example">
|
| 108 |
<div className="font-cluster-demo">
|
|
@@ -122,18 +131,18 @@ const AboutModal = ({ onClose, darkMode }) => {
|
|
| 122 |
<div className="about-column">
|
| 123 |
<h3 className="about-section-title">Open Source</h3>
|
| 124 |
<p className="about-section-text">
|
| 125 |
-
This project is <strong>completely open source</strong>
|
| 126 |
</p>
|
| 127 |
<p className="about-section-text">
|
| 128 |
-
The <strong>complete dataset</strong> is also open source, including all font metadata and positioning data from <a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer" className="google-fonts-link">Google Fonts</a>.
|
| 129 |
</p>
|
| 130 |
<a href="https://huggingface.co/spaces/huggingface/fontmap" target="_blank" rel="noopener noreferrer" className="code-link">
|
| 131 |
-
View Source on Hugging Face
|
| 132 |
</a>
|
| 133 |
|
| 134 |
<h3 className="about-section-title">About This Project</h3>
|
| 135 |
<p className="about-section-text">
|
| 136 |
-
|
| 137 |
</p>
|
| 138 |
</div>
|
| 139 |
</div>
|
|
|
|
| 26 |
|
| 27 |
<div className="about-modal-body">
|
| 28 |
<p className="about-description">
|
| 29 |
+
FontMap uses <strong>FontCLIP</strong>, a vision model fine-tuned for typography, to build an interactive map of font relationships.
|
| 30 |
+
Each font is encoded into a rich embedding, then projected onto a 2D plane via <strong>UMAP</strong> so that visually similar fonts cluster together.
|
| 31 |
</p>
|
| 32 |
|
| 33 |
<div className="about-content-grid">
|
|
|
|
| 41 |
<div className="step-content">
|
| 42 |
<div className="step-header">
|
| 43 |
<span className="step-number">1</span>
|
| 44 |
+
<strong>Multi-Glyph Rendering</strong>
|
| 45 |
</div>
|
| 46 |
+
<p className="step-description">Each font is rendered as a <strong>224 × 224</strong> composite image showing multiple representative glyphs, capturing the full typographic character</p>
|
| 47 |
</div>
|
| 48 |
<div className="step-example">
|
| 49 |
+
<div className="multi-glyph-demo">
|
| 50 |
+
<div className="glyph-grid">
|
| 51 |
+
<span style={{fontFamily: 'Georgia, serif', fontSize: '18px', fontWeight: 'bold'}}>Aa</span>
|
| 52 |
+
<span style={{fontFamily: 'Georgia, serif', fontSize: '18px'}}>Bb</span>
|
| 53 |
+
<span style={{fontFamily: 'Georgia, serif', fontSize: '18px'}}>Rr</span>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="glyph-grid-label">224 × 224</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
</div>
|
|
|
|
| 61 |
<div className="step-content">
|
| 62 |
<div className="step-header">
|
| 63 |
<span className="step-number">2</span>
|
| 64 |
+
<strong>FontCLIP Embeddings</strong>
|
| 65 |
</div>
|
| 66 |
+
<p className="step-description">
|
| 67 |
+
A <strong>CLIP ViT-B/32</strong> model <a href="https://github.com/yukistavailable/FontCLIP" target="_blank" rel="noopener noreferrer" className="inline-link">fine-tuned for typography</a> encodes each image into a <strong>512-dimensional vector</strong> that captures weight, contrast, style and structure
|
| 68 |
+
</p>
|
| 69 |
</div>
|
| 70 |
<div className="step-example">
|
| 71 |
<div className="mini-matrix">
|
| 72 |
+
<div className="mini-row"><strong>[ 0.42</strong>, -0.13, <strong>0.87</strong>, 0.05, -0.61,</div>
|
| 73 |
+
<div className="mini-row"> 0.29, <strong>-0.74</strong>, 0.18, 0.51, -0.33,</div>
|
| 74 |
+
<div className="mini-row"> 0.02, <strong>0.96</strong>, -0.47, 0.11, 0.68,</div>
|
| 75 |
+
<div className="mini-row"> -0.22, 0.39, <strong>-0.85</strong>, ... <strong>]</strong></div>
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
</div>
|
|
|
|
| 81 |
<div className="step-content">
|
| 82 |
<div className="step-header">
|
| 83 |
<span className="step-number">3</span>
|
| 84 |
+
<strong>PCA + Font Fusion + UMAP</strong>
|
| 85 |
</div>
|
| 86 |
<p className="step-description">
|
| 87 |
+
<strong>PCA</strong> reduces noise (512D → 50D), font family variants are <strong>merged</strong> into single representatives, then <strong>spectral UMAP</strong> projects to 2D
|
| 88 |
<a href="https://pair-code.github.io/understanding-umap/" target="_blank" rel="noopener noreferrer" className="inline-link"> (Learn more about UMAP)</a>
|
| 89 |
</p>
|
| 90 |
</div>
|
| 91 |
<div className="step-example">
|
| 92 |
+
<div className="pipeline-flow">
|
| 93 |
+
<div className="pipeline-node">512D</div>
|
| 94 |
+
<div className="pipeline-arrow">→</div>
|
| 95 |
+
<div className="pipeline-node">50D</div>
|
| 96 |
+
<div className="pipeline-arrow">→</div>
|
| 97 |
+
<div className="pipeline-node">fusion</div>
|
| 98 |
+
<div className="pipeline-arrow">→</div>
|
| 99 |
+
<div className="pipeline-node"><strong>2D</strong></div>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="coordinates" style={{marginTop: '6px'}}>
|
| 102 |
<div className="coord">x: -2.34</div>
|
| 103 |
<div className="coord">y: 1.67</div>
|
| 104 |
</div>
|
|
|
|
| 111 |
<span className="step-number">4</span>
|
| 112 |
<strong>Interactive Visualization</strong>
|
| 113 |
</div>
|
| 114 |
+
<p className="step-description">Fonts are positioned on a <strong>2D map</strong> where proximity indicates <strong>visual similarity</strong> — nearest neighbors are also pre-computed in high-dimensional space</p>
|
| 115 |
</div>
|
| 116 |
<div className="step-example">
|
| 117 |
<div className="font-cluster-demo">
|
|
|
|
| 131 |
<div className="about-column">
|
| 132 |
<h3 className="about-section-title">Open Source</h3>
|
| 133 |
<p className="about-section-text">
|
| 134 |
+
This project is <strong>completely open source</strong> — you can explore the code, modify the parameters, or run it on your own font collection.
|
| 135 |
</p>
|
| 136 |
<p className="about-section-text">
|
| 137 |
+
The <strong>complete dataset</strong> is also open source, including all font metadata, FontCLIP embeddings and positioning data from <a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer" className="google-fonts-link">Google Fonts</a>.
|
| 138 |
</p>
|
| 139 |
<a href="https://huggingface.co/spaces/huggingface/fontmap" target="_blank" rel="noopener noreferrer" className="code-link">
|
| 140 |
+
View Source on Hugging Face →
|
| 141 |
</a>
|
| 142 |
|
| 143 |
<h3 className="about-section-title">About This Project</h3>
|
| 144 |
<p className="about-section-text">
|
| 145 |
+
Inspired by the original <strong>IDEO Font Map</strong>, this version replaces pixel-based features with <strong>FontCLIP</strong> embeddings — a CLIP model fine-tuned specifically for typography — producing a more semantically meaningful layout where fonts group by visual style rather than raw pixel similarity.
|
| 146 |
</p>
|
| 147 |
</div>
|
| 148 |
</div>
|
src/components/FontMap/components/ActiveFont.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { getFontSymbolId, generateGoogleFontsUrl } from '../utils/fontUtils';
|
| 3 |
import Spinner from '../../Spinner';
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
/**
|
| 6 |
* Component to display the active font details and similar fonts
|
| 7 |
*/
|
|
@@ -12,95 +23,99 @@ const ActiveFont = ({ selectedFont, fonts, darkMode, onClose, onFontSelect }) =>
|
|
| 12 |
const imageLoadTimeouts = useRef({});
|
| 13 |
const mainSentenceTimeout = useRef(null);
|
| 14 |
const [placeholderFontIndex, setPlaceholderFontIndex] = useState(0);
|
| 15 |
-
|
| 16 |
-
// Fonts par défaut du navigateur pour la rotation
|
| 17 |
-
const defaultFonts = [
|
| 18 |
-
'Arial, sans-serif',
|
| 19 |
-
'Times New Roman, serif',
|
| 20 |
-
'Georgia, serif',
|
| 21 |
-
'Helvetica, sans-serif',
|
| 22 |
-
'Courier New, monospace',
|
| 23 |
-
'Verdana, sans-serif',
|
| 24 |
-
'Trebuchet MS, sans-serif',
|
| 25 |
-
'Tahoma, sans-serif'
|
| 26 |
-
];
|
| 27 |
|
| 28 |
-
// Reset loading states
|
| 29 |
useEffect(() => {
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if (shouldResetStates) {
|
| 35 |
-
setFontPreviewLoaded(false);
|
| 36 |
-
setSentencePreviewLoaded(false);
|
| 37 |
-
setSimilarFontsLoaded({});
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
// Clear any existing timeouts
|
| 41 |
Object.values(imageLoadTimeouts.current).forEach(timeout => clearTimeout(timeout));
|
| 42 |
imageLoadTimeouts.current = {};
|
| 43 |
-
|
| 44 |
-
// Clear main sentence timeout
|
| 45 |
if (mainSentenceTimeout.current) {
|
| 46 |
clearTimeout(mainSentenceTimeout.current);
|
| 47 |
mainSentenceTimeout.current = null;
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
//
|
| 51 |
-
const timer = setTimeout(() =>
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
// Set timeout for main sentence SVG loading
|
| 56 |
-
mainSentenceTimeout.current = setTimeout(() => {
|
| 57 |
-
setSentencePreviewLoaded(true);
|
| 58 |
-
}, 500);
|
| 59 |
-
|
| 60 |
return () => {
|
| 61 |
clearTimeout(timer);
|
| 62 |
if (mainSentenceTimeout.current) {
|
| 63 |
clearTimeout(mainSentenceTimeout.current);
|
| 64 |
}
|
| 65 |
};
|
| 66 |
-
}, [selectedFont
|
| 67 |
|
| 68 |
-
// Effet pour faire tourner les polices dans le placeholder
|
| 69 |
useEffect(() => {
|
| 70 |
-
if (selectedFont) return;
|
| 71 |
-
|
| 72 |
const interval = setInterval(() => {
|
| 73 |
-
setPlaceholderFontIndex(prev => (prev + 1) %
|
| 74 |
-
}, 200);
|
| 75 |
-
|
| 76 |
return () => clearInterval(interval);
|
| 77 |
-
}, [selectedFont
|
| 78 |
|
| 79 |
-
|
| 80 |
-
const handleImageLoad = (fontName) => {
|
| 81 |
-
// Clear any existing timeout for this font
|
| 82 |
if (imageLoadTimeouts.current[fontName]) {
|
| 83 |
clearTimeout(imageLoadTimeouts.current[fontName]);
|
| 84 |
delete imageLoadTimeouts.current[fontName];
|
| 85 |
}
|
| 86 |
-
|
| 87 |
setSimilarFontsLoaded(prev => ({ ...prev, [fontName]: true }));
|
| 88 |
-
};
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
if (
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
-
|
| 97 |
-
// Set a timeout to hide spinner if image doesn't load
|
| 98 |
-
imageLoadTimeouts.current[fontName] = setTimeout(() => {
|
| 99 |
-
handleImageLoad(fontName);
|
| 100 |
-
}, 1000); // 1 second timeout for SVG
|
| 101 |
-
};
|
| 102 |
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if (!selectedFont) {
|
| 105 |
return (
|
| 106 |
<div className="font-details">
|
|
@@ -118,7 +133,7 @@ const ActiveFont = ({ selectedFont, fonts, darkMode, onClose, onFontSelect }) =>
|
|
| 118 |
fontWeight="100"
|
| 119 |
opacity={0.65}
|
| 120 |
style={{
|
| 121 |
-
fontFamily:
|
| 122 |
fontStyle: 'normal',
|
| 123 |
fontVariant: 'normal'
|
| 124 |
}}
|
|
@@ -136,38 +151,6 @@ const ActiveFont = ({ selectedFont, fonts, darkMode, onClose, onFontSelect }) =>
|
|
| 136 |
);
|
| 137 |
}
|
| 138 |
|
| 139 |
-
// Function to calculate the euclidean distance between two points
|
| 140 |
-
const calculateDistance = (point1, point2) => {
|
| 141 |
-
if (!point1 || !point2) return Infinity;
|
| 142 |
-
const dx = point1.x - point2.x;
|
| 143 |
-
const dy = point1.y - point2.y;
|
| 144 |
-
return Math.sqrt(dx * dx + dy * dy);
|
| 145 |
-
};
|
| 146 |
-
|
| 147 |
-
// Find the 5 closest fonts
|
| 148 |
-
const findSimilarFonts = () => {
|
| 149 |
-
if (!selectedFont || !fonts || fonts.length === 0) return [];
|
| 150 |
-
|
| 151 |
-
const selectedFontData = fonts.find(f => f.name === selectedFont.name);
|
| 152 |
-
if (!selectedFontData) return [];
|
| 153 |
-
|
| 154 |
-
// Calculate distances for all other fonts
|
| 155 |
-
const distances = fonts
|
| 156 |
-
.filter(font => font.name !== selectedFont.name)
|
| 157 |
-
.map(font => ({
|
| 158 |
-
font,
|
| 159 |
-
distance: calculateDistance(
|
| 160 |
-
{ x: selectedFontData.x, y: selectedFontData.y },
|
| 161 |
-
{ x: font.x, y: font.y }
|
| 162 |
-
)
|
| 163 |
-
}))
|
| 164 |
-
.sort((a, b) => a.distance - b.distance)
|
| 165 |
-
.slice(0, 4); // Take the 4 closest
|
| 166 |
-
|
| 167 |
-
return distances;
|
| 168 |
-
};
|
| 169 |
-
|
| 170 |
-
const similarFonts = findSimilarFonts();
|
| 171 |
const symbolId = getFontSymbolId(selectedFont.imageName || selectedFont.name);
|
| 172 |
const category = selectedFont.family || 'sans-serif';
|
| 173 |
|
|
@@ -305,15 +288,10 @@ const ActiveFont = ({ selectedFont, fonts, darkMode, onClose, onFontSelect }) =>
|
|
| 305 |
<p className="no-similar">No similar fonts found</p>
|
| 306 |
) : (
|
| 307 |
<div className="similar-fonts-list">
|
| 308 |
-
{similarFonts.map(({ font, distance }
|
| 309 |
const imageName = font.imageName || font.name;
|
| 310 |
const sentenceImagePath = `/data/sentences/${imageName.toLowerCase().replace(/\s+/g, '_')}_sentence.svg`;
|
| 311 |
-
|
| 312 |
-
// Set timeout for this font if not already loaded
|
| 313 |
-
if (!similarFontsLoaded[font.name] && !imageLoadTimeouts.current[font.name]) {
|
| 314 |
-
setImageLoadTimeout(font.name);
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
return (
|
| 318 |
<div
|
| 319 |
key={font.name}
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
| 2 |
import { getFontSymbolId, generateGoogleFontsUrl } from '../utils/fontUtils';
|
| 3 |
import Spinner from '../../Spinner';
|
| 4 |
|
| 5 |
+
const DEFAULT_FONTS = [
|
| 6 |
+
'Arial, sans-serif',
|
| 7 |
+
'Times New Roman, serif',
|
| 8 |
+
'Georgia, serif',
|
| 9 |
+
'Helvetica, sans-serif',
|
| 10 |
+
'Courier New, monospace',
|
| 11 |
+
'Verdana, sans-serif',
|
| 12 |
+
'Trebuchet MS, sans-serif',
|
| 13 |
+
'Tahoma, sans-serif'
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
/**
|
| 17 |
* Component to display the active font details and similar fonts
|
| 18 |
*/
|
|
|
|
| 23 |
const imageLoadTimeouts = useRef({});
|
| 24 |
const mainSentenceTimeout = useRef(null);
|
| 25 |
const [placeholderFontIndex, setPlaceholderFontIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
// Reset loading states UNIQUEMENT quand selectedFont change
|
| 28 |
useEffect(() => {
|
| 29 |
+
setFontPreviewLoaded(false);
|
| 30 |
+
setSentencePreviewLoaded(false);
|
| 31 |
+
setSimilarFontsLoaded({});
|
| 32 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
Object.values(imageLoadTimeouts.current).forEach(timeout => clearTimeout(timeout));
|
| 34 |
imageLoadTimeouts.current = {};
|
| 35 |
+
|
|
|
|
| 36 |
if (mainSentenceTimeout.current) {
|
| 37 |
clearTimeout(mainSentenceTimeout.current);
|
| 38 |
mainSentenceTimeout.current = null;
|
| 39 |
}
|
| 40 |
+
|
| 41 |
+
// <use> elements don't fire onLoad — simulate with a short timeout
|
| 42 |
+
const timer = setTimeout(() => setFontPreviewLoaded(true), 100);
|
| 43 |
+
|
| 44 |
+
mainSentenceTimeout.current = setTimeout(() => setSentencePreviewLoaded(true), 500);
|
| 45 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return () => {
|
| 47 |
clearTimeout(timer);
|
| 48 |
if (mainSentenceTimeout.current) {
|
| 49 |
clearTimeout(mainSentenceTimeout.current);
|
| 50 |
}
|
| 51 |
};
|
| 52 |
+
}, [selectedFont]);
|
| 53 |
|
|
|
|
| 54 |
useEffect(() => {
|
| 55 |
+
if (selectedFont) return;
|
| 56 |
+
|
| 57 |
const interval = setInterval(() => {
|
| 58 |
+
setPlaceholderFontIndex(prev => (prev + 1) % DEFAULT_FONTS.length);
|
| 59 |
+
}, 200);
|
| 60 |
+
|
| 61 |
return () => clearInterval(interval);
|
| 62 |
+
}, [selectedFont]);
|
| 63 |
|
| 64 |
+
const handleImageLoad = useCallback((fontName) => {
|
|
|
|
|
|
|
| 65 |
if (imageLoadTimeouts.current[fontName]) {
|
| 66 |
clearTimeout(imageLoadTimeouts.current[fontName]);
|
| 67 |
delete imageLoadTimeouts.current[fontName];
|
| 68 |
}
|
|
|
|
| 69 |
setSimilarFontsLoaded(prev => ({ ...prev, [fontName]: true }));
|
| 70 |
+
}, []);
|
| 71 |
|
| 72 |
+
const similarFonts = useMemo(() => {
|
| 73 |
+
if (!selectedFont || !fonts || fonts.length === 0) return [];
|
| 74 |
+
const sel = fonts.find(f => f.name === selectedFont.name);
|
| 75 |
+
if (!sel) return [];
|
| 76 |
+
|
| 77 |
+
// Use pre-computed high-dimensional k-NN neighbors when available
|
| 78 |
+
if (sel.neighbors && sel.neighbors.length > 0) {
|
| 79 |
+
const fontById = new Map(fonts.map(f => [f.id, f]));
|
| 80 |
+
return sel.neighbors
|
| 81 |
+
.map((neighborId, rank) => {
|
| 82 |
+
const font = fontById.get(neighborId);
|
| 83 |
+
if (!font) return null;
|
| 84 |
+
return { font, distance: rank + 1 };
|
| 85 |
+
})
|
| 86 |
+
.filter(Boolean)
|
| 87 |
+
.slice(0, 4);
|
| 88 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
// Fallback: Euclidean distance in UMAP 2D space
|
| 91 |
+
return fonts
|
| 92 |
+
.filter(f => f.name !== selectedFont.name)
|
| 93 |
+
.map(f => {
|
| 94 |
+
const dx = f.x - sel.x;
|
| 95 |
+
const dy = f.y - sel.y;
|
| 96 |
+
return { font: f, distance: Math.sqrt(dx * dx + dy * dy) };
|
| 97 |
+
})
|
| 98 |
+
.sort((a, b) => a.distance - b.distance)
|
| 99 |
+
.slice(0, 4);
|
| 100 |
+
}, [selectedFont, fonts]);
|
| 101 |
+
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
if (similarFonts.length === 0) return;
|
| 104 |
+
const newTimeouts = {};
|
| 105 |
+
similarFonts.forEach(({ font }) => {
|
| 106 |
+
if (!similarFontsLoaded[font.name] && !imageLoadTimeouts.current[font.name]) {
|
| 107 |
+
const id = setTimeout(() => {
|
| 108 |
+
handleImageLoad(font.name);
|
| 109 |
+
}, 1000);
|
| 110 |
+
imageLoadTimeouts.current[font.name] = id;
|
| 111 |
+
newTimeouts[font.name] = id;
|
| 112 |
+
}
|
| 113 |
+
});
|
| 114 |
+
return () => {
|
| 115 |
+
Object.values(newTimeouts).forEach(id => clearTimeout(id));
|
| 116 |
+
};
|
| 117 |
+
}, [similarFonts, similarFontsLoaded, handleImageLoad]);
|
| 118 |
+
|
| 119 |
if (!selectedFont) {
|
| 120 |
return (
|
| 121 |
<div className="font-details">
|
|
|
|
| 133 |
fontWeight="100"
|
| 134 |
opacity={0.65}
|
| 135 |
style={{
|
| 136 |
+
fontFamily: DEFAULT_FONTS[placeholderFontIndex],
|
| 137 |
fontStyle: 'normal',
|
| 138 |
fontVariant: 'normal'
|
| 139 |
}}
|
|
|
|
| 151 |
);
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
const symbolId = getFontSymbolId(selectedFont.imageName || selectedFont.name);
|
| 155 |
const category = selectedFont.family || 'sans-serif';
|
| 156 |
|
|
|
|
| 288 |
<p className="no-similar">No similar fonts found</p>
|
| 289 |
) : (
|
| 290 |
<div className="similar-fonts-list">
|
| 291 |
+
{similarFonts.map(({ font, distance }) => {
|
| 292 |
const imageName = font.imageName || font.name;
|
| 293 |
const sentenceImagePath = `/data/sentences/${imageName.toLowerCase().replace(/\s+/g, '_')}_sentence.svg`;
|
| 294 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
return (
|
| 296 |
<div
|
| 297 |
key={font.name}
|
src/components/FontMap/components/TooltipManager.js
CHANGED
|
@@ -21,12 +21,11 @@ const TooltipManager = ({
|
|
| 21 |
// Gérer la police sélectionnée
|
| 22 |
useEffect(() => {
|
| 23 |
if (selectedFont) {
|
| 24 |
-
// Trouver l'élément SVG correspondant
|
| 25 |
const svg = document.querySelector('.fontmap-svg');
|
| 26 |
if (svg) {
|
| 27 |
const viewportGroup = svg.querySelector('.viewport-group');
|
| 28 |
if (viewportGroup) {
|
| 29 |
-
const selectedGlyph = viewportGroup.querySelector(`[data-font="${selectedFont.name}"]`);
|
| 30 |
if (selectedGlyph) {
|
| 31 |
handleFontSelect(selectedFont, selectedGlyph);
|
| 32 |
}
|
|
@@ -40,12 +39,11 @@ const TooltipManager = ({
|
|
| 40 |
// Gérer la police survolée
|
| 41 |
useEffect(() => {
|
| 42 |
if (hoveredFont && (!selectedFont || selectedFont.name !== hoveredFont.name)) {
|
| 43 |
-
// Trouver l'élément SVG correspondant
|
| 44 |
const svg = document.querySelector('.fontmap-svg');
|
| 45 |
if (svg) {
|
| 46 |
const viewportGroup = svg.querySelector('.viewport-group');
|
| 47 |
if (viewportGroup) {
|
| 48 |
-
const hoveredGlyph = viewportGroup.querySelector(`[data-font="${hoveredFont.name}"]`);
|
| 49 |
if (hoveredGlyph) {
|
| 50 |
handleFontHover(hoveredFont, hoveredGlyph);
|
| 51 |
}
|
|
|
|
| 21 |
// Gérer la police sélectionnée
|
| 22 |
useEffect(() => {
|
| 23 |
if (selectedFont) {
|
|
|
|
| 24 |
const svg = document.querySelector('.fontmap-svg');
|
| 25 |
if (svg) {
|
| 26 |
const viewportGroup = svg.querySelector('.viewport-group');
|
| 27 |
if (viewportGroup) {
|
| 28 |
+
const selectedGlyph = viewportGroup.querySelector(`[data-font="${CSS.escape(selectedFont.name)}"]`);
|
| 29 |
if (selectedGlyph) {
|
| 30 |
handleFontSelect(selectedFont, selectedGlyph);
|
| 31 |
}
|
|
|
|
| 39 |
// Gérer la police survolée
|
| 40 |
useEffect(() => {
|
| 41 |
if (hoveredFont && (!selectedFont || selectedFont.name !== hoveredFont.name)) {
|
|
|
|
| 42 |
const svg = document.querySelector('.fontmap-svg');
|
| 43 |
if (svg) {
|
| 44 |
const viewportGroup = svg.querySelector('.viewport-group');
|
| 45 |
if (viewportGroup) {
|
| 46 |
+
const hoveredGlyph = viewportGroup.querySelector(`[data-font="${CSS.escape(hoveredFont.name)}"]`);
|
| 47 |
if (hoveredGlyph) {
|
| 48 |
handleFontHover(hoveredFont, hoveredGlyph);
|
| 49 |
}
|
src/components/FontMap/components/controls/CategoryLegend.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { useFontMapStore } from '../../../../store/fontMapStore';
|
| 3 |
+
|
| 4 |
+
const CategoryLegend = () => {
|
| 5 |
+
const useCategoryColors = useFontMapStore(s => s.useCategoryColors);
|
| 6 |
+
const setUseCategoryColors = useFontMapStore(s => s.setUseCategoryColors);
|
| 7 |
+
|
| 8 |
+
return (
|
| 9 |
+
<div className="category-legend">
|
| 10 |
+
<label className="category-legend-toggle">
|
| 11 |
+
<input
|
| 12 |
+
type="checkbox"
|
| 13 |
+
checked={useCategoryColors}
|
| 14 |
+
onChange={(e) => setUseCategoryColors(e.target.checked)}
|
| 15 |
+
/>
|
| 16 |
+
<span>Category colors</span>
|
| 17 |
+
</label>
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default CategoryLegend;
|
src/components/FontMap/hooks/useMapRenderer.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react';
|
| 2 |
+
import * as d3 from 'd3';
|
| 3 |
+
import { useFontMapStore } from '../../../store/fontMapStore';
|
| 4 |
+
|
| 5 |
+
const BATCH_SIZE = 50;
|
| 6 |
+
const BATCH_DELAY = 10;
|
| 7 |
+
const GLYPH_SCALE = 0.25;
|
| 8 |
+
|
| 9 |
+
const CATEGORY_COLORS = {
|
| 10 |
+
'sans-serif': '#3498db',
|
| 11 |
+
'serif': '#e74c3c',
|
| 12 |
+
'display': '#f39c12',
|
| 13 |
+
'handwriting':'#9b59b6',
|
| 14 |
+
'monospace': '#2ecc71',
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
function getGlyphColor(category, useCategoryColors, darkMode) {
|
| 18 |
+
if (useCategoryColors) {
|
| 19 |
+
return CATEGORY_COLORS[category] || '#95a5a6';
|
| 20 |
+
}
|
| 21 |
+
return darkMode ? '#ffffff' : '#333333';
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function calculateMappingDimensions(fonts, width, height, padding = 40) {
|
| 25 |
+
const xValues = fonts.map(d => d.x);
|
| 26 |
+
const yValues = fonts.map(d => d.y);
|
| 27 |
+
|
| 28 |
+
const xMin = Math.min(...xValues);
|
| 29 |
+
const xMax = Math.max(...xValues);
|
| 30 |
+
const yMin = Math.min(...yValues);
|
| 31 |
+
const yMax = Math.max(...yValues);
|
| 32 |
+
|
| 33 |
+
const mapX = (x) => ((x - xMin) / (xMax - xMin)) * (width - 2 * padding) + padding;
|
| 34 |
+
const mapY = (y) => ((yMax - y) / (yMax - yMin)) * (height - 2 * padding) + padding;
|
| 35 |
+
|
| 36 |
+
return { mapX, mapY };
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Retourne ou crée le viewport-group (partagé avec useMapZoom).
|
| 41 |
+
* Ne touche PAS au reste du SVG pour ne pas casser le zoom D3.
|
| 42 |
+
*/
|
| 43 |
+
function getOrCreateViewportGroup(svg) {
|
| 44 |
+
let vg = svg.select('.viewport-group');
|
| 45 |
+
if (vg.empty()) {
|
| 46 |
+
vg = svg.append('g').attr('class', 'viewport-group');
|
| 47 |
+
}
|
| 48 |
+
return vg;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Hook de rendu de la carte — moteur DebugUMAP
|
| 53 |
+
* (viewBox + SVGs individuels + batch loading) avec interactions FontMap.
|
| 54 |
+
*/
|
| 55 |
+
export function useMapRenderer({ svgRef, fonts, filter, searchTerm, darkMode, loading, enabled = true }) {
|
| 56 |
+
const abortControllerRef = useRef(null);
|
| 57 |
+
const timeoutRefs = useRef([]);
|
| 58 |
+
const mappingRef = useRef({ mapX: null, mapY: null });
|
| 59 |
+
const dimensionsRef = useRef({ width: 0, height: 0 });
|
| 60 |
+
const hasRenderedRef = useRef(false);
|
| 61 |
+
const selectedFontRef = useRef(null);
|
| 62 |
+
|
| 63 |
+
const {
|
| 64 |
+
selectedFont,
|
| 65 |
+
setSelectedFont,
|
| 66 |
+
setHoveredFont,
|
| 67 |
+
useCategoryColors
|
| 68 |
+
} = useFontMapStore();
|
| 69 |
+
|
| 70 |
+
// Ref synchronisée pour éviter de rebind les event listeners à chaque sélection
|
| 71 |
+
selectedFontRef.current = selectedFont;
|
| 72 |
+
|
| 73 |
+
// ── Rendu principal : configurer le viewBox et charger les glyphes ──
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (!enabled || !fonts || fonts.length === 0 || !svgRef.current) return;
|
| 76 |
+
|
| 77 |
+
// Cleanup des opérations précédentes
|
| 78 |
+
if (abortControllerRef.current) abortControllerRef.current.abort();
|
| 79 |
+
timeoutRefs.current.forEach(t => clearTimeout(t));
|
| 80 |
+
timeoutRefs.current = [];
|
| 81 |
+
|
| 82 |
+
abortControllerRef.current = new AbortController();
|
| 83 |
+
|
| 84 |
+
const svg = d3.select(svgRef.current);
|
| 85 |
+
const parentEl = svgRef.current.parentElement;
|
| 86 |
+
if (!parentEl) return;
|
| 87 |
+
|
| 88 |
+
const width = parentEl.clientWidth || window.innerWidth;
|
| 89 |
+
const height = parentEl.clientHeight || window.innerHeight;
|
| 90 |
+
dimensionsRef.current = { width, height };
|
| 91 |
+
|
| 92 |
+
// viewBox = clé de l'antialiasing (comme DebugUMAP MapContainer)
|
| 93 |
+
svg
|
| 94 |
+
.attr('width', '100%')
|
| 95 |
+
.attr('height', '100%')
|
| 96 |
+
.attr('viewBox', `0 0 ${width} ${height}`);
|
| 97 |
+
|
| 98 |
+
// Créer ou récupérer le viewport-group — NE PAS supprimer le SVG entier
|
| 99 |
+
const viewportGroup = getOrCreateViewportGroup(svg);
|
| 100 |
+
|
| 101 |
+
// Nettoyer uniquement les glyphes existants (pas le zoom/viewport)
|
| 102 |
+
viewportGroup.selectAll('g.glyph-group').remove();
|
| 103 |
+
|
| 104 |
+
// Calculer le mapping
|
| 105 |
+
const { mapX, mapY } = calculateMappingDimensions(fonts, width, height);
|
| 106 |
+
mappingRef.current = { mapX, mapY };
|
| 107 |
+
|
| 108 |
+
// Charger les glyphes par batch
|
| 109 |
+
const loadBatch = (startIndex) => {
|
| 110 |
+
const endIndex = Math.min(startIndex + BATCH_SIZE, fonts.length);
|
| 111 |
+
const batch = fonts.slice(startIndex, endIndex);
|
| 112 |
+
|
| 113 |
+
const promises = batch.map(font =>
|
| 114 |
+
fetch(`/data/char/${font.id}_a.svg`, {
|
| 115 |
+
signal: abortControllerRef.current.signal
|
| 116 |
+
})
|
| 117 |
+
.then(res => res.text())
|
| 118 |
+
.then(svgContent => {
|
| 119 |
+
if (!svgRef.current || abortControllerRef.current.signal.aborted) return;
|
| 120 |
+
renderGlyph(viewportGroup, svgContent, font, mapX, mapY, darkMode, useCategoryColors);
|
| 121 |
+
})
|
| 122 |
+
.catch(err => {
|
| 123 |
+
if (err.name !== 'AbortError') {
|
| 124 |
+
console.warn('Glyph load error:', font.id, err);
|
| 125 |
+
}
|
| 126 |
+
})
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
Promise.all(promises).then(() => {
|
| 130 |
+
if (!svgRef.current || abortControllerRef.current.signal.aborted) return;
|
| 131 |
+
if (endIndex < fonts.length) {
|
| 132 |
+
const timeout = setTimeout(() => loadBatch(endIndex), BATCH_DELAY);
|
| 133 |
+
timeoutRefs.current.push(timeout);
|
| 134 |
+
} else {
|
| 135 |
+
hasRenderedRef.current = true;
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
loadBatch(0);
|
| 141 |
+
|
| 142 |
+
return () => {
|
| 143 |
+
if (abortControllerRef.current) abortControllerRef.current.abort();
|
| 144 |
+
timeoutRefs.current.forEach(t => clearTimeout(t));
|
| 145 |
+
timeoutRefs.current = [];
|
| 146 |
+
};
|
| 147 |
+
}, [enabled, fonts, darkMode, useCategoryColors, svgRef]);
|
| 148 |
+
|
| 149 |
+
// ── Mise à jour des couleurs (dark mode / category colors toggle) ──
|
| 150 |
+
useEffect(() => {
|
| 151 |
+
if (!svgRef.current) return;
|
| 152 |
+
const viewportGroup = svgRef.current.querySelector('.viewport-group');
|
| 153 |
+
if (!viewportGroup) return;
|
| 154 |
+
|
| 155 |
+
viewportGroup.querySelectorAll('g.glyph-group').forEach(group => {
|
| 156 |
+
const category = group.getAttribute('data-category');
|
| 157 |
+
const color = getGlyphColor(category, useCategoryColors, darkMode);
|
| 158 |
+
group.querySelectorAll('*').forEach(el => {
|
| 159 |
+
if (el.nodeType === Node.ELEMENT_NODE) {
|
| 160 |
+
el.setAttribute('fill', color);
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
});
|
| 164 |
+
}, [darkMode, useCategoryColors, svgRef]);
|
| 165 |
+
|
| 166 |
+
// ── Labels centroïdes sur la map ──
|
| 167 |
+
useEffect(() => {
|
| 168 |
+
if (!svgRef.current || !fonts || fonts.length === 0) return;
|
| 169 |
+
const svg = d3.select(svgRef.current);
|
| 170 |
+
const viewportGroup = svg.select('.viewport-group');
|
| 171 |
+
if (viewportGroup.empty()) return;
|
| 172 |
+
|
| 173 |
+
// Labels go in a sibling group AFTER viewport-group so they render on top
|
| 174 |
+
svg.selectAll('.centroids-group').remove();
|
| 175 |
+
|
| 176 |
+
const { mapX, mapY } = mappingRef.current;
|
| 177 |
+
if (!mapX || !mapY) return;
|
| 178 |
+
|
| 179 |
+
const centroids = {};
|
| 180 |
+
fonts.forEach(font => {
|
| 181 |
+
const cat = font.family;
|
| 182 |
+
if (!centroids[cat]) centroids[cat] = { x: 0, y: 0, n: 0 };
|
| 183 |
+
centroids[cat].x += font.x;
|
| 184 |
+
centroids[cat].y += font.y;
|
| 185 |
+
centroids[cat].n += 1;
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Copy the current viewport transform so labels follow zoom/pan
|
| 189 |
+
const currentTransform = viewportGroup.attr('transform') || '';
|
| 190 |
+
const centroidsGroup = svg.append('g')
|
| 191 |
+
.attr('class', 'centroids-group')
|
| 192 |
+
.attr('transform', currentTransform)
|
| 193 |
+
.style('pointer-events', 'none')
|
| 194 |
+
.style('user-select', 'none');
|
| 195 |
+
|
| 196 |
+
const fillColor = useCategoryColors
|
| 197 |
+
? null
|
| 198 |
+
: (darkMode ? '#ffffff' : '#000000');
|
| 199 |
+
|
| 200 |
+
Object.entries(centroids).forEach(([cat, c]) => {
|
| 201 |
+
const x = mapX(c.x / c.n);
|
| 202 |
+
const y = mapY(c.y / c.n);
|
| 203 |
+
const color = fillColor || (CATEGORY_COLORS[cat] || '#95a5a6');
|
| 204 |
+
|
| 205 |
+
centroidsGroup.append('text')
|
| 206 |
+
.attr('x', x)
|
| 207 |
+
.attr('y', y)
|
| 208 |
+
.attr('text-anchor', 'middle')
|
| 209 |
+
.attr('font-size', '16px')
|
| 210 |
+
.attr('font-weight', 'bold')
|
| 211 |
+
.attr('fill', color)
|
| 212 |
+
.attr('stroke', '#ffffff')
|
| 213 |
+
.attr('stroke-width', '8px')
|
| 214 |
+
.attr('paint-order', 'stroke fill')
|
| 215 |
+
.attr('class', 'centroid-label')
|
| 216 |
+
.text(cat);
|
| 217 |
+
});
|
| 218 |
+
}, [fonts, useCategoryColors, darkMode, svgRef]);
|
| 219 |
+
|
| 220 |
+
// ── Isolation visuelle (sélection) + opacité (filtre/recherche) ──
|
| 221 |
+
useEffect(() => {
|
| 222 |
+
if (!svgRef.current) return;
|
| 223 |
+
const svg = d3.select(svgRef.current);
|
| 224 |
+
const viewportGroup = svg.select('.viewport-group');
|
| 225 |
+
if (viewportGroup.empty()) return;
|
| 226 |
+
|
| 227 |
+
// Toujours nettoyer le highlight précédent
|
| 228 |
+
svg.selectAll('.highlight-group').remove();
|
| 229 |
+
|
| 230 |
+
if (selectedFont) {
|
| 231 |
+
// ── Mode isolation : dim le groupe entier (1 seule op DOM) ──
|
| 232 |
+
viewportGroup.attr('opacity', 0.1);
|
| 233 |
+
|
| 234 |
+
// Cloner le glyphe sélectionné dans un groupe frère hors du dim
|
| 235 |
+
const selectedGlyph = viewportGroup.select(
|
| 236 |
+
`g.glyph-group[data-font-id="${selectedFont.id}"]`
|
| 237 |
+
);
|
| 238 |
+
|
| 239 |
+
if (!selectedGlyph.empty()) {
|
| 240 |
+
const currentTransform = viewportGroup.attr('transform') || '';
|
| 241 |
+
const highlightGroup = svg.append('g')
|
| 242 |
+
.attr('class', 'highlight-group')
|
| 243 |
+
.attr('transform', currentTransform)
|
| 244 |
+
.style('pointer-events', 'none');
|
| 245 |
+
|
| 246 |
+
const clone = selectedGlyph.node().cloneNode(true);
|
| 247 |
+
clone.setAttribute('opacity', '1');
|
| 248 |
+
highlightGroup.node().appendChild(clone);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Tous les glyphes restent cliquables pour changer de sélection
|
| 252 |
+
viewportGroup.selectAll('g.glyph-group')
|
| 253 |
+
.style('pointer-events', 'all');
|
| 254 |
+
} else {
|
| 255 |
+
// ── Mode normal : restaurer l'opacité du groupe ──
|
| 256 |
+
viewportGroup.attr('opacity', 1);
|
| 257 |
+
|
| 258 |
+
const hasFilter = filter !== 'all' || searchTerm;
|
| 259 |
+
|
| 260 |
+
if (hasFilter) {
|
| 261 |
+
const searchLower = searchTerm ? searchTerm.toLowerCase() : '';
|
| 262 |
+
viewportGroup.selectAll('g.glyph-group').each(function () {
|
| 263 |
+
const group = this;
|
| 264 |
+
const fontFamily = group.getAttribute('data-category');
|
| 265 |
+
const fontName = group.getAttribute('data-font-name');
|
| 266 |
+
|
| 267 |
+
const familyMatch = filter === 'all' || fontFamily === filter;
|
| 268 |
+
const searchMatch = !searchLower ||
|
| 269 |
+
(fontName && fontName.toLowerCase().includes(searchLower)) ||
|
| 270 |
+
(fontFamily && fontFamily.toLowerCase().includes(searchLower));
|
| 271 |
+
|
| 272 |
+
const match = familyMatch && searchMatch;
|
| 273 |
+
group.setAttribute('opacity', match ? '1' : '0.06');
|
| 274 |
+
group.style.pointerEvents = match ? 'all' : 'none';
|
| 275 |
+
});
|
| 276 |
+
} else {
|
| 277 |
+
// Aucun filtre → retirer les attributs d'opacité (état par défaut SVG = 1)
|
| 278 |
+
viewportGroup.selectAll('g.glyph-group').each(function () {
|
| 279 |
+
this.removeAttribute('opacity');
|
| 280 |
+
this.style.pointerEvents = 'all';
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
}, [filter, searchTerm, selectedFont, svgRef]);
|
| 285 |
+
|
| 286 |
+
// ── Interactions : hover et click (délégation d'événements) ──
|
| 287 |
+
useEffect(() => {
|
| 288 |
+
if (!svgRef.current || !fonts || fonts.length === 0) return;
|
| 289 |
+
|
| 290 |
+
const svg = svgRef.current;
|
| 291 |
+
|
| 292 |
+
const findGlyphGroup = (target) => {
|
| 293 |
+
let el = target;
|
| 294 |
+
while (el && el !== svg) {
|
| 295 |
+
if (el.classList && el.classList.contains('glyph-group')) return el;
|
| 296 |
+
el = el.parentElement;
|
| 297 |
+
}
|
| 298 |
+
return null;
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
const getFontFromGroup = (group) => {
|
| 302 |
+
const fontId = group.getAttribute('data-font-id');
|
| 303 |
+
return fonts.find(f => f.id === fontId) || null;
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
const handleMouseOver = (e) => {
|
| 307 |
+
if (useFontMapStore.getState().isTransitioning) return;
|
| 308 |
+
const group = findGlyphGroup(e.target);
|
| 309 |
+
if (!group) return;
|
| 310 |
+
const font = getFontFromGroup(group);
|
| 311 |
+
if (font) setHoveredFont(font);
|
| 312 |
+
};
|
| 313 |
+
|
| 314 |
+
const handleMouseOut = (e) => {
|
| 315 |
+
if (useFontMapStore.getState().isTransitioning) return;
|
| 316 |
+
const group = findGlyphGroup(e.target);
|
| 317 |
+
if (!group) return;
|
| 318 |
+
setHoveredFont(null);
|
| 319 |
+
};
|
| 320 |
+
|
| 321 |
+
const handleClick = (e) => {
|
| 322 |
+
const group = findGlyphGroup(e.target);
|
| 323 |
+
if (!group) {
|
| 324 |
+
if (selectedFontRef.current) setSelectedFont(null);
|
| 325 |
+
return;
|
| 326 |
+
}
|
| 327 |
+
const font = getFontFromGroup(group);
|
| 328 |
+
if (font) {
|
| 329 |
+
setHoveredFont(null);
|
| 330 |
+
const cur = selectedFontRef.current;
|
| 331 |
+
setSelectedFont(cur && cur.id === font.id ? null : font);
|
| 332 |
+
}
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
svg.addEventListener('mouseover', handleMouseOver);
|
| 336 |
+
svg.addEventListener('mouseout', handleMouseOut);
|
| 337 |
+
svg.addEventListener('click', handleClick);
|
| 338 |
+
|
| 339 |
+
return () => {
|
| 340 |
+
svg.removeEventListener('mouseover', handleMouseOver);
|
| 341 |
+
svg.removeEventListener('mouseout', handleMouseOut);
|
| 342 |
+
svg.removeEventListener('click', handleClick);
|
| 343 |
+
};
|
| 344 |
+
}, [fonts, setSelectedFont, setHoveredFont, svgRef]);
|
| 345 |
+
|
| 346 |
+
return { mappingRef, dimensionsRef };
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// ── Rendu d'un glyphe individuel ──
|
| 350 |
+
|
| 351 |
+
function renderGlyph(viewportGroup, svgContent, font, mapX, mapY, darkMode, useCategoryColors) {
|
| 352 |
+
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
| 353 |
+
const x = mapX(font.x);
|
| 354 |
+
const y = mapY(font.y);
|
| 355 |
+
|
| 356 |
+
group.setAttribute('transform', `translate(${x}, ${y}) scale(${GLYPH_SCALE})`);
|
| 357 |
+
group.setAttribute('data-original-transform', `translate(${x}, ${y})`);
|
| 358 |
+
group.setAttribute('data-font', font.name);
|
| 359 |
+
group.setAttribute('data-font-id', font.id);
|
| 360 |
+
group.setAttribute('data-font-name', font.name);
|
| 361 |
+
group.setAttribute('data-category', font.family);
|
| 362 |
+
group.setAttribute('class', 'glyph-group');
|
| 363 |
+
group.style.cursor = 'pointer';
|
| 364 |
+
|
| 365 |
+
const parser = new DOMParser();
|
| 366 |
+
const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml');
|
| 367 |
+
const svgElement = svgDoc.querySelector('svg');
|
| 368 |
+
|
| 369 |
+
if (svgElement) {
|
| 370 |
+
while (svgElement.firstChild) {
|
| 371 |
+
group.appendChild(svgElement.firstChild);
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const color = getGlyphColor(font.family, useCategoryColors, darkMode);
|
| 376 |
+
group.querySelectorAll('*').forEach(el => {
|
| 377 |
+
if (el.nodeType === Node.ELEMENT_NODE) {
|
| 378 |
+
el.setAttribute('fill', color);
|
| 379 |
+
}
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
viewportGroup.node().appendChild(group);
|
| 383 |
+
}
|
src/components/FontMap/hooks/useMapZoom.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import * as d3 from 'd3';
|
| 3 |
+
import { useFontMapStore } from '../../../store/fontMapStore';
|
| 4 |
+
|
| 5 |
+
const SCALE_EXTENT = [0.4, 10.0];
|
| 6 |
+
const INITIAL_SCALE = 0.8;
|
| 7 |
+
const TRANSITION_DURATION = 750;
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Hook de zoom basé sur DebugUMAP.
|
| 11 |
+
* Fonctionne avec un SVG viewBox → antialiasing natif.
|
| 12 |
+
*
|
| 13 |
+
* Le handler sélectionne .viewport-group dynamiquement pour rester
|
| 14 |
+
* compatible avec useMapRenderer qui peut le recréer.
|
| 15 |
+
*/
|
| 16 |
+
export function useMapZoom(svgRef, enabled = true) {
|
| 17 |
+
const zoomRef = useRef(null);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
if (!enabled || !svgRef.current) return;
|
| 21 |
+
|
| 22 |
+
const svg = d3.select(svgRef.current);
|
| 23 |
+
|
| 24 |
+
// Le viewport-group doit exister (créé par useMapRenderer)
|
| 25 |
+
if (svg.select('.viewport-group').empty()) return;
|
| 26 |
+
|
| 27 |
+
// Nettoyer un éventuel zoom précédent
|
| 28 |
+
svg.on('.zoom', null);
|
| 29 |
+
|
| 30 |
+
const zoom = d3.zoom()
|
| 31 |
+
.scaleExtent(SCALE_EXTENT)
|
| 32 |
+
.on('zoom', (event) => {
|
| 33 |
+
svg.select('.viewport-group').attr('transform', event.transform);
|
| 34 |
+
svg.select('.highlight-group').attr('transform', event.transform);
|
| 35 |
+
svg.select('.centroids-group').attr('transform', event.transform);
|
| 36 |
+
if (window.updateTooltipTransform) window.updateTooltipTransform(event.transform);
|
| 37 |
+
if (window.updateTooltipPositions) window.updateTooltipPositions();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
svg.call(zoom);
|
| 41 |
+
|
| 42 |
+
// Zoom initial centré à 80 %
|
| 43 |
+
const svgRect = svg.node().getBoundingClientRect();
|
| 44 |
+
const cx = svgRect.width / 2;
|
| 45 |
+
const cy = svgRect.height / 2;
|
| 46 |
+
const initialTransform = d3.zoomIdentity
|
| 47 |
+
.translate(cx * (1 - INITIAL_SCALE), cy * (1 - INITIAL_SCALE))
|
| 48 |
+
.scale(INITIAL_SCALE);
|
| 49 |
+
svg.call(zoom.transform, initialTransform);
|
| 50 |
+
|
| 51 |
+
zoomRef.current = zoom;
|
| 52 |
+
|
| 53 |
+
// Fonctions globales pour ZoomControls
|
| 54 |
+
window.zoomIn = () => svg.transition().duration(200).call(zoom.scaleBy, 1.5);
|
| 55 |
+
window.zoomOut = () => svg.transition().duration(200).call(zoom.scaleBy, 1 / 1.5);
|
| 56 |
+
window.resetZoom = () => {
|
| 57 |
+
const rect = svg.node().getBoundingClientRect();
|
| 58 |
+
const rcx = rect.width / 2;
|
| 59 |
+
const rcy = rect.height / 2;
|
| 60 |
+
const t = d3.zoomIdentity
|
| 61 |
+
.translate(rcx * (1 - INITIAL_SCALE), rcy * (1 - INITIAL_SCALE))
|
| 62 |
+
.scale(INITIAL_SCALE);
|
| 63 |
+
const store = useFontMapStore.getState();
|
| 64 |
+
store.setIsTransitioning(true);
|
| 65 |
+
store.setHoveredFont(null);
|
| 66 |
+
svg.transition().duration(TRANSITION_DURATION).call(zoom.transform, t)
|
| 67 |
+
.on('end', () => {
|
| 68 |
+
useFontMapStore.getState().setIsTransitioning(false);
|
| 69 |
+
});
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const svgNode = svgRef.current;
|
| 73 |
+
return () => {
|
| 74 |
+
if (svgNode) d3.select(svgNode).on('.zoom', null);
|
| 75 |
+
delete window.zoomIn;
|
| 76 |
+
delete window.zoomOut;
|
| 77 |
+
delete window.resetZoom;
|
| 78 |
+
zoomRef.current = null;
|
| 79 |
+
};
|
| 80 |
+
}, [enabled, svgRef]);
|
| 81 |
+
|
| 82 |
+
const centerOnFont = useCallback((font) => {
|
| 83 |
+
if (!font || !zoomRef.current || !svgRef.current) return;
|
| 84 |
+
|
| 85 |
+
const svg = d3.select(svgRef.current);
|
| 86 |
+
const glyphGroup = svg.select(`g.glyph-group[data-font-id="${font.id}"]`);
|
| 87 |
+
if (glyphGroup.empty()) return;
|
| 88 |
+
|
| 89 |
+
const transformAttr = glyphGroup.attr('data-original-transform');
|
| 90 |
+
if (!transformAttr) return;
|
| 91 |
+
|
| 92 |
+
const match = transformAttr.match(/translate\(([^,]+),\s*([^)]+)\)/);
|
| 93 |
+
if (!match) return;
|
| 94 |
+
|
| 95 |
+
const fontX = parseFloat(match[1]);
|
| 96 |
+
const fontY = parseFloat(match[2]);
|
| 97 |
+
|
| 98 |
+
const svgNode = svgRef.current;
|
| 99 |
+
const width = svgNode.clientWidth || svgNode.getBoundingClientRect().width;
|
| 100 |
+
const height = svgNode.clientHeight || svgNode.getBoundingClientRect().height;
|
| 101 |
+
|
| 102 |
+
const scale = 2.5;
|
| 103 |
+
const translateX = width / 2 - fontX * scale;
|
| 104 |
+
const translateY = height / 2 - fontY * scale;
|
| 105 |
+
|
| 106 |
+
const transform = d3.zoomIdentity
|
| 107 |
+
.translate(translateX, translateY)
|
| 108 |
+
.scale(scale);
|
| 109 |
+
|
| 110 |
+
const store = useFontMapStore.getState();
|
| 111 |
+
store.setIsTransitioning(true);
|
| 112 |
+
store.setHoveredFont(null);
|
| 113 |
+
|
| 114 |
+
svg.transition()
|
| 115 |
+
.duration(800)
|
| 116 |
+
.ease(d3.easeCubicInOut)
|
| 117 |
+
.call(zoomRef.current.transform, transform)
|
| 118 |
+
.on('end', () => {
|
| 119 |
+
useFontMapStore.getState().setIsTransitioning(false);
|
| 120 |
+
});
|
| 121 |
+
}, [svgRef]);
|
| 122 |
+
|
| 123 |
+
const resetZoom = useCallback(() => {
|
| 124 |
+
if (window.resetZoom) window.resetZoom();
|
| 125 |
+
}, []);
|
| 126 |
+
|
| 127 |
+
return { centerOnFont, resetZoom };
|
| 128 |
+
}
|
src/components/FontMap/hooks/useTooltipOptimized.js
CHANGED
|
@@ -169,27 +169,23 @@ export const useTooltipOptimized = (darkMode) => {
|
|
| 169 |
|
| 170 |
// Mettre à jour le tooltip sélectionné
|
| 171 |
if (selectedTooltipRef.current && selectedTooltipRef.current.style('opacity') !== '0') {
|
| 172 |
-
const
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
const glyphElement = selectedGlyph.node();
|
| 179 |
-
positionTooltip(selectedTooltipRef.current, glyphElement);
|
| 180 |
}
|
| 181 |
}
|
| 182 |
|
| 183 |
// Mettre à jour le tooltip hover
|
| 184 |
if (hoverTooltipRef.current && hoverTooltipRef.current.style('opacity') !== '0') {
|
| 185 |
-
const
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
const glyphElement = hoveredGlyph.node();
|
| 192 |
-
positionTooltip(hoverTooltipRef.current, glyphElement);
|
| 193 |
}
|
| 194 |
}
|
| 195 |
}, [positionTooltip]);
|
|
|
|
| 169 |
|
| 170 |
// Mettre à jour le tooltip sélectionné
|
| 171 |
if (selectedTooltipRef.current && selectedTooltipRef.current.style('opacity') !== '0') {
|
| 172 |
+
const selectedName = window.currentSelectedFont?.name;
|
| 173 |
+
if (selectedName) {
|
| 174 |
+
const glyphElement = viewportGroup.node().querySelector(`[data-font="${CSS.escape(selectedName)}"]`);
|
| 175 |
+
if (glyphElement) {
|
| 176 |
+
positionTooltip(selectedTooltipRef.current, glyphElement);
|
| 177 |
+
}
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
}
|
| 180 |
|
| 181 |
// Mettre à jour le tooltip hover
|
| 182 |
if (hoverTooltipRef.current && hoverTooltipRef.current.style('opacity') !== '0') {
|
| 183 |
+
const hoveredName = window.currentHoveredFont?.name;
|
| 184 |
+
if (hoveredName) {
|
| 185 |
+
const glyphElement = viewportGroup.node().querySelector(`[data-font="${CSS.escape(hoveredName)}"]`);
|
| 186 |
+
if (glyphElement) {
|
| 187 |
+
positionTooltip(hoverTooltipRef.current, glyphElement);
|
| 188 |
+
}
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
}, [positionTooltip]);
|
src/components/FontMap/styles/about-modal.css
CHANGED
|
@@ -395,6 +395,81 @@
|
|
| 395 |
}
|
| 396 |
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
/* Animation */
|
| 399 |
@keyframes modalSlideIn {
|
| 400 |
from {
|
|
|
|
| 395 |
}
|
| 396 |
|
| 397 |
|
| 398 |
+
/* Multi-Glyph Demo */
|
| 399 |
+
.multi-glyph-demo {
|
| 400 |
+
display: flex;
|
| 401 |
+
flex-direction: column;
|
| 402 |
+
align-items: center;
|
| 403 |
+
gap: 4px;
|
| 404 |
+
padding: 8px 10px;
|
| 405 |
+
background: var(--color-bg-primary);
|
| 406 |
+
border-radius: 2px;
|
| 407 |
+
border: 1px solid var(--color-border-primary);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.dark-mode .multi-glyph-demo {
|
| 411 |
+
background: var(--color-bg-primary-dark);
|
| 412 |
+
border-color: var(--color-border-primary-dark);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
.glyph-grid {
|
| 416 |
+
display: flex;
|
| 417 |
+
gap: 8px;
|
| 418 |
+
color: var(--color-text-primary);
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.dark-mode .glyph-grid {
|
| 422 |
+
color: var(--color-text-primary-dark);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.glyph-grid-label {
|
| 426 |
+
font-family: 'Courier New', monospace;
|
| 427 |
+
font-size: 9px;
|
| 428 |
+
color: var(--color-text-secondary);
|
| 429 |
+
opacity: 0.6;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.dark-mode .glyph-grid-label {
|
| 433 |
+
color: var(--color-text-secondary-dark);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* Pipeline Flow */
|
| 437 |
+
.pipeline-flow {
|
| 438 |
+
display: flex;
|
| 439 |
+
align-items: center;
|
| 440 |
+
gap: 4px;
|
| 441 |
+
font-family: 'Courier New', monospace;
|
| 442 |
+
font-size: 10px;
|
| 443 |
+
background: var(--color-bg-primary);
|
| 444 |
+
padding: 6px 8px;
|
| 445 |
+
border-radius: 2px;
|
| 446 |
+
border: 1px solid var(--color-border-primary);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.dark-mode .pipeline-flow {
|
| 450 |
+
background: var(--color-bg-primary-dark);
|
| 451 |
+
border-color: var(--color-border-primary-dark);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.pipeline-node {
|
| 455 |
+
color: var(--color-text-primary);
|
| 456 |
+
font-weight: 500;
|
| 457 |
+
white-space: nowrap;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.dark-mode .pipeline-node {
|
| 461 |
+
color: var(--color-text-primary-dark);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.pipeline-arrow {
|
| 465 |
+
color: var(--color-text-secondary);
|
| 466 |
+
opacity: 0.5;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.dark-mode .pipeline-arrow {
|
| 470 |
+
color: var(--color-text-secondary-dark);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
/* Animation */
|
| 474 |
@keyframes modalSlideIn {
|
| 475 |
from {
|
src/components/FontMap/styles/intro-modal.css
CHANGED
|
@@ -10,7 +10,6 @@
|
|
| 10 |
align-items: center;
|
| 11 |
justify-content: center;
|
| 12 |
z-index: 99999;
|
| 13 |
-
backdrop-filter: blur(4px);
|
| 14 |
opacity: 1;
|
| 15 |
}
|
| 16 |
|
|
|
|
| 10 |
align-items: center;
|
| 11 |
justify-content: center;
|
| 12 |
z-index: 99999;
|
|
|
|
| 13 |
opacity: 1;
|
| 14 |
}
|
| 15 |
|
src/components/FontMap/styles/layout.css
CHANGED
|
@@ -15,8 +15,6 @@
|
|
| 15 |
transition: background-color var(--transition-quick);
|
| 16 |
display: flex;
|
| 17 |
flex-direction: row;
|
| 18 |
-
/* GPU simple */
|
| 19 |
-
will-change: transform;
|
| 20 |
}
|
| 21 |
|
| 22 |
.fontmap-container.dark-mode {
|
|
@@ -83,7 +81,6 @@
|
|
| 83 |
align-items: center;
|
| 84 |
justify-content: center;
|
| 85 |
z-index: 50;
|
| 86 |
-
backdrop-filter: blur(2px);
|
| 87 |
}
|
| 88 |
|
| 89 |
.dark-mode .map-loading-overlay {
|
|
@@ -256,17 +253,10 @@
|
|
| 256 |
height: 100%;
|
| 257 |
display: block;
|
| 258 |
cursor: grab;
|
| 259 |
-
/* Rendu vectoriel optimisé
|
| 260 |
shape-rendering: geometricPrecision;
|
| 261 |
text-rendering: geometricPrecision;
|
| 262 |
image-rendering: crisp-edges;
|
| 263 |
-
/* Force le rendu vectoriel */
|
| 264 |
-
vector-effect: non-scaling-stroke;
|
| 265 |
-
/* GPU simple et direct */
|
| 266 |
-
will-change: transform;
|
| 267 |
-
/* Responsive */
|
| 268 |
-
max-width: 100%;
|
| 269 |
-
max-height: 100%;
|
| 270 |
/* Zoom and pan */
|
| 271 |
touch-action: none;
|
| 272 |
-webkit-touch-callout: none;
|
|
@@ -275,9 +265,6 @@
|
|
| 275 |
-moz-user-select: none;
|
| 276 |
-ms-user-select: none;
|
| 277 |
user-select: none;
|
| 278 |
-
/* Force le rendu haute qualité */
|
| 279 |
-
-webkit-font-smoothing: antialiased;
|
| 280 |
-
-moz-osx-font-smoothing: grayscale;
|
| 281 |
}
|
| 282 |
|
| 283 |
.fontmap-svg:active {
|
|
@@ -285,10 +272,9 @@
|
|
| 285 |
}
|
| 286 |
|
| 287 |
/* Font glyphs with expanded hit area */
|
| 288 |
-
.font-glyph-group
|
|
|
|
| 289 |
cursor: pointer;
|
| 290 |
-
/* GPU simple */
|
| 291 |
-
will-change: transform;
|
| 292 |
}
|
| 293 |
|
| 294 |
.font-hit-area {
|
|
@@ -297,64 +283,26 @@
|
|
| 297 |
|
| 298 |
.font-glyph {
|
| 299 |
pointer-events: none;
|
| 300 |
-
/* Rendu vectoriel optimisé */
|
| 301 |
shape-rendering: geometricPrecision;
|
| 302 |
text-rendering: geometricPrecision;
|
| 303 |
image-rendering: crisp-edges;
|
| 304 |
-
vector-effect: non-scaling-stroke;
|
| 305 |
-
/* GPU simple */
|
| 306 |
-
will-change: transform;
|
| 307 |
-
}
|
| 308 |
-
|
| 309 |
-
/* Specific styles for SVG use elements */
|
| 310 |
-
.font-glyph use {
|
| 311 |
-
shape-rendering: geometricPrecision !important;
|
| 312 |
-
text-rendering: geometricPrecision !important;
|
| 313 |
-
image-rendering: crisp-edges !important;
|
| 314 |
-
vector-effect: non-scaling-stroke !important;
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
/* Ensure crisp rendering at all zoom levels */
|
| 318 |
-
.fontmap-svg use {
|
| 319 |
-
shape-rendering: geometricPrecision !important;
|
| 320 |
-
text-rendering: geometricPrecision !important;
|
| 321 |
-
image-rendering: crisp-edges !important;
|
| 322 |
}
|
| 323 |
|
| 324 |
-
/*
|
| 325 |
.viewport-group {
|
| 326 |
will-change: transform;
|
| 327 |
-
/* Force le rendu vectoriel sur le groupe de viewport */
|
| 328 |
shape-rendering: geometricPrecision;
|
| 329 |
text-rendering: geometricPrecision;
|
| 330 |
image-rendering: crisp-edges;
|
| 331 |
-
/* Optimisation pour les transformations matrix */
|
| 332 |
transform-origin: 0 0;
|
| 333 |
}
|
| 334 |
|
| 335 |
-
/*
|
| 336 |
-
.
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
cursor: grabbing;
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
/* Force le rendu haute qualité pendant le zoom */
|
| 345 |
-
.fontmap-svg.zooming .viewport-group {
|
| 346 |
-
/* Force le rendu vectoriel pendant le zoom */
|
| 347 |
-
shape-rendering: geometricPrecision !important;
|
| 348 |
-
text-rendering: geometricPrecision !important;
|
| 349 |
-
image-rendering: crisp-edges !important;
|
| 350 |
-
}
|
| 351 |
-
|
| 352 |
-
/* Optimisation pour les glyphes pendant le zoom */
|
| 353 |
-
.fontmap-svg.zooming .font-glyph {
|
| 354 |
-
shape-rendering: geometricPrecision !important;
|
| 355 |
-
text-rendering: geometricPrecision !important;
|
| 356 |
-
image-rendering: crisp-edges !important;
|
| 357 |
-
vector-effect: non-scaling-stroke !important;
|
| 358 |
}
|
| 359 |
|
| 360 |
/* Force glyph colors in dark mode */
|
|
@@ -404,3 +352,85 @@
|
|
| 404 |
.dark-mode .sidebar-separator {
|
| 405 |
background: var(--color-border-primary-dark);
|
| 406 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
transition: background-color var(--transition-quick);
|
| 16 |
display: flex;
|
| 17 |
flex-direction: row;
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
.fontmap-container.dark-mode {
|
|
|
|
| 81 |
align-items: center;
|
| 82 |
justify-content: center;
|
| 83 |
z-index: 50;
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
.dark-mode .map-loading-overlay {
|
|
|
|
| 253 |
height: 100%;
|
| 254 |
display: block;
|
| 255 |
cursor: grab;
|
| 256 |
+
/* Rendu vectoriel optimisé */
|
| 257 |
shape-rendering: geometricPrecision;
|
| 258 |
text-rendering: geometricPrecision;
|
| 259 |
image-rendering: crisp-edges;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
/* Zoom and pan */
|
| 261 |
touch-action: none;
|
| 262 |
-webkit-touch-callout: none;
|
|
|
|
| 265 |
-moz-user-select: none;
|
| 266 |
-ms-user-select: none;
|
| 267 |
user-select: none;
|
|
|
|
|
|
|
|
|
|
| 268 |
}
|
| 269 |
|
| 270 |
.fontmap-svg:active {
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
/* Font glyphs with expanded hit area */
|
| 275 |
+
.font-glyph-group,
|
| 276 |
+
.glyph-group {
|
| 277 |
cursor: pointer;
|
|
|
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
.font-hit-area {
|
|
|
|
| 283 |
|
| 284 |
.font-glyph {
|
| 285 |
pointer-events: none;
|
|
|
|
| 286 |
shape-rendering: geometricPrecision;
|
| 287 |
text-rendering: geometricPrecision;
|
| 288 |
image-rendering: crisp-edges;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
}
|
| 290 |
|
| 291 |
+
/* Viewport group — aligné avec DebugUMAP */
|
| 292 |
.viewport-group {
|
| 293 |
will-change: transform;
|
|
|
|
| 294 |
shape-rendering: geometricPrecision;
|
| 295 |
text-rendering: geometricPrecision;
|
| 296 |
image-rendering: crisp-edges;
|
|
|
|
| 297 |
transform-origin: 0 0;
|
| 298 |
}
|
| 299 |
|
| 300 |
+
/* Highlight group — clone isolé de la font active, hors du dim */
|
| 301 |
+
.highlight-group {
|
| 302 |
+
shape-rendering: geometricPrecision;
|
| 303 |
+
text-rendering: geometricPrecision;
|
| 304 |
+
image-rendering: crisp-edges;
|
| 305 |
+
transform-origin: 0 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
/* Force glyph colors in dark mode */
|
|
|
|
| 352 |
.dark-mode .sidebar-separator {
|
| 353 |
background: var(--color-border-primary-dark);
|
| 354 |
}
|
| 355 |
+
|
| 356 |
+
/* Category Legend — small card on the map, aligned with zoom buttons */
|
| 357 |
+
.category-legend {
|
| 358 |
+
display: flex;
|
| 359 |
+
flex-direction: column;
|
| 360 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 361 |
+
background: var(--color-bg-primary);
|
| 362 |
+
border: 1px solid var(--color-border-primary);
|
| 363 |
+
border-radius: var(--border-radius-md);
|
| 364 |
+
box-shadow: var(--shadow-sm);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.dark-mode .category-legend {
|
| 368 |
+
background: var(--color-bg-primary-dark);
|
| 369 |
+
border-color: var(--color-border-primary-dark);
|
| 370 |
+
box-shadow: 0 1px 3px rgba(255, 255, 255, 0.1);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.category-legend-toggle {
|
| 374 |
+
display: flex;
|
| 375 |
+
align-items: center;
|
| 376 |
+
gap: 6px;
|
| 377 |
+
cursor: pointer;
|
| 378 |
+
font-size: 10px;
|
| 379 |
+
font-weight: 500;
|
| 380 |
+
color: var(--color-text-primary);
|
| 381 |
+
user-select: none;
|
| 382 |
+
white-space: nowrap;
|
| 383 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 384 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 385 |
+
sans-serif;
|
| 386 |
+
text-transform: uppercase;
|
| 387 |
+
letter-spacing: 0.3px;
|
| 388 |
+
line-height: 14px;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.dark-mode .category-legend-toggle {
|
| 392 |
+
color: var(--color-text-primary-dark);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.category-legend-toggle input[type="checkbox"] {
|
| 396 |
+
width: 12px;
|
| 397 |
+
height: 12px;
|
| 398 |
+
margin: 0;
|
| 399 |
+
cursor: pointer;
|
| 400 |
+
accent-color: var(--color-text-primary);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.dark-mode .category-legend-toggle input[type="checkbox"] {
|
| 404 |
+
accent-color: var(--color-text-primary-dark);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.category-legend-items {
|
| 408 |
+
display: flex;
|
| 409 |
+
flex-wrap: wrap;
|
| 410 |
+
gap: var(--spacing-xs) var(--spacing-md);
|
| 411 |
+
margin-top: 2px;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.category-legend-item {
|
| 415 |
+
display: flex;
|
| 416 |
+
align-items: center;
|
| 417 |
+
gap: 6px;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.category-legend-dot {
|
| 421 |
+
width: 10px;
|
| 422 |
+
height: 10px;
|
| 423 |
+
border-radius: 50%;
|
| 424 |
+
flex-shrink: 0;
|
| 425 |
+
transition: background 0.2s ease;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.category-legend-label {
|
| 429 |
+
font-size: 12px;
|
| 430 |
+
color: var(--color-text-secondary);
|
| 431 |
+
text-transform: capitalize;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.dark-mode .category-legend-label {
|
| 435 |
+
color: var(--color-text-secondary-dark);
|
| 436 |
+
}
|
src/hooks/useStaticFontData.js
CHANGED
|
@@ -48,22 +48,24 @@ export function useStaticFontData() {
|
|
| 48 |
console.warn('⚠️ Error loading/parsing sprite:', e);
|
| 49 |
}
|
| 50 |
|
| 51 |
-
// 2. Charger les données JSON
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
if (
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
throw new Error('Font map data not found (got HTML instead of JSON). Please export it from Debug view first.');
|
| 61 |
-
}
|
| 62 |
-
throw new Error(`HTTP Error: ${response.status}`);
|
| 63 |
}
|
| 64 |
|
| 65 |
-
const data = await response.json();
|
| 66 |
-
|
| 67 |
if (!data.fonts || !Array.isArray(data.fonts)) {
|
| 68 |
throw new Error('Invalid data format: missing fonts array');
|
| 69 |
}
|
|
|
|
| 48 |
console.warn('⚠️ Error loading/parsing sprite:', e);
|
| 49 |
}
|
| 50 |
|
| 51 |
+
// 2. Charger les données JSON (font-map.json ou fallback typography_data.json)
|
| 52 |
+
let data;
|
| 53 |
+
const mapResponse = await fetch('/data/font-map.json');
|
| 54 |
+
const mapContentType = mapResponse.headers.get('content-type');
|
| 55 |
|
| 56 |
+
if (mapResponse.ok && mapContentType && mapContentType.includes('application/json')) {
|
| 57 |
+
data = await mapResponse.json();
|
| 58 |
+
console.log('📍 Loaded from font-map.json');
|
| 59 |
+
} else {
|
| 60 |
+
console.warn('⚠️ font-map.json not available, falling back to typography_data.json');
|
| 61 |
+
const fallbackResponse = await fetch('/data/typography_data.json');
|
| 62 |
+
if (!fallbackResponse.ok) {
|
| 63 |
+
throw new Error('No font data found. Neither font-map.json nor typography_data.json available.');
|
| 64 |
}
|
| 65 |
+
data = await fallbackResponse.json();
|
| 66 |
+
console.log('📍 Loaded from typography_data.json (fallback)');
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
| 69 |
if (!data.fonts || !Array.isArray(data.fonts)) {
|
| 70 |
throw new Error('Invalid data format: missing fonts array');
|
| 71 |
}
|
src/store/fontMapStore.js
CHANGED
|
@@ -12,6 +12,10 @@ export const useFontMapStore = create((set, get) => ({
|
|
| 12 |
// État de visualisation
|
| 13 |
characterSize: 1.5,
|
| 14 |
variantSizeImpact: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
// Mode debug
|
| 17 |
debugMode: false,
|
|
@@ -38,6 +42,10 @@ export const useFontMapStore = create((set, get) => ({
|
|
| 38 |
set({ variantSizeImpact: impact });
|
| 39 |
},
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
// Actions pour le debug
|
| 42 |
setDebugMode: (debug) => {
|
| 43 |
console.log('🎯 Store: Setting debugMode to', debug);
|
|
@@ -50,8 +58,10 @@ export const useFontMapStore = create((set, get) => ({
|
|
| 50 |
set({
|
| 51 |
selectedFont: null,
|
| 52 |
hoveredFont: null,
|
|
|
|
| 53 |
characterSize: 1.5,
|
| 54 |
variantSizeImpact: false,
|
|
|
|
| 55 |
debugMode: false
|
| 56 |
});
|
| 57 |
}
|
|
|
|
| 12 |
// État de visualisation
|
| 13 |
characterSize: 1.5,
|
| 14 |
variantSizeImpact: false,
|
| 15 |
+
useCategoryColors: false,
|
| 16 |
+
|
| 17 |
+
// Animated transition state (zoom/pan to font)
|
| 18 |
+
isTransitioning: false,
|
| 19 |
|
| 20 |
// Mode debug
|
| 21 |
debugMode: false,
|
|
|
|
| 42 |
set({ variantSizeImpact: impact });
|
| 43 |
},
|
| 44 |
|
| 45 |
+
setUseCategoryColors: (val) => set({ useCategoryColors: val }),
|
| 46 |
+
|
| 47 |
+
setIsTransitioning: (val) => set({ isTransitioning: val }),
|
| 48 |
+
|
| 49 |
// Actions pour le debug
|
| 50 |
setDebugMode: (debug) => {
|
| 51 |
console.log('🎯 Store: Setting debugMode to', debug);
|
|
|
|
| 58 |
set({
|
| 59 |
selectedFont: null,
|
| 60 |
hoveredFont: null,
|
| 61 |
+
isTransitioning: false,
|
| 62 |
characterSize: 1.5,
|
| 63 |
variantSizeImpact: false,
|
| 64 |
+
useCategoryColors: false,
|
| 65 |
debugMode: false
|
| 66 |
});
|
| 67 |
}
|
src/typography/new-pipe/2-generate-svgs.mjs
CHANGED
|
@@ -60,7 +60,7 @@ function validateSVGQuality(svg, fontFamily) {
|
|
| 60 |
}
|
| 61 |
|
| 62 |
/**
|
| 63 |
-
* Génère un SVG de la lettre A à partir d'une police
|
| 64 |
*/
|
| 65 |
async function generateLetterASVG(fontPath, fontFamily) {
|
| 66 |
try {
|
|
@@ -133,6 +133,93 @@ async function generateLetterASVG(fontPath, fontFamily) {
|
|
| 133 |
}
|
| 134 |
}
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
/**
|
| 137 |
* Génère un SVG de phrase à partir d'une police (vectorisé)
|
| 138 |
*/
|
|
@@ -224,7 +311,7 @@ async function processFontFamily(fontId, fontData, currentIndex, totalFamilies)
|
|
| 224 |
|
| 225 |
const fontPath = path.join(fontDir, ttfFile);
|
| 226 |
|
| 227 |
-
// Générer le SVG de la lettre A
|
| 228 |
const letterResult = await generateLetterASVG(fontPath, fontData.family);
|
| 229 |
if (!letterResult) {
|
| 230 |
throw new Error('Failed de génération du SVG de la lettre A');
|
|
@@ -233,6 +320,15 @@ async function processFontFamily(fontId, fontData, currentIndex, totalFamilies)
|
|
| 233 |
// Sauvegarder le SVG de la lettre A
|
| 234 |
const letterSvgPath = path.join(SVGS_DIR, `${fontId}_a.svg`);
|
| 235 |
await fs.writeFile(letterSvgPath, letterResult.svg, 'utf-8');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
// Générer le SVG de la phrase
|
| 238 |
const sentenceResult = await generateSentenceSVG(fontPath, fontData.family);
|
|
@@ -249,11 +345,16 @@ async function processFontFamily(fontId, fontData, currentIndex, totalFamilies)
|
|
| 249 |
fontId,
|
| 250 |
fontFamily: fontData.family,
|
| 251 |
letterSvg: letterSvgPath,
|
|
|
|
| 252 |
sentenceSvg: sentenceSvgPath,
|
| 253 |
letterDimensions: {
|
| 254 |
width: letterResult.width,
|
| 255 |
height: letterResult.height
|
| 256 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
sentenceDimensions: {
|
| 258 |
width: sentenceResult.width,
|
| 259 |
height: sentenceResult.height,
|
|
@@ -310,11 +411,11 @@ async function main() {
|
|
| 310 |
console.log(chalk.magenta(`\n🔤 [${i + 1}/${fontIds.length}] Traitement de "${fontData.family}" (${fontId})`));
|
| 311 |
|
| 312 |
// Démarrer la barre de progression
|
| 313 |
-
progressBar.start(
|
| 314 |
|
| 315 |
const result = await processFontFamily(fontId, fontData, i, fontIds.length);
|
| 316 |
|
| 317 |
-
progressBar.update(
|
| 318 |
progressBar.stop();
|
| 319 |
|
| 320 |
results.push(result);
|
|
@@ -323,6 +424,7 @@ async function main() {
|
|
| 323 |
successCount++;
|
| 324 |
console.log(chalk.green(`✅ SVGs générés pour "${fontData.family}"`));
|
| 325 |
console.log(chalk.blue(` - Lettre A: ${result.letterDimensions.width}x${result.letterDimensions.height}`));
|
|
|
|
| 326 |
console.log(chalk.blue(` - Phrase: ${result.sentenceDimensions.width}x${result.sentenceDimensions.height} (largeur mesurée: ${result.sentenceDimensions.measuredWidth.toFixed(1)}px)`));
|
| 327 |
} else {
|
| 328 |
errorCount++;
|
|
@@ -375,7 +477,7 @@ async function main() {
|
|
| 375 |
console.log(chalk.cyan('📊 Summary :'));
|
| 376 |
console.log(chalk.white(` - ${successCount} familles traitées avec succès`));
|
| 377 |
console.log(chalk.white(` - ${errorCount} erreurs`));
|
| 378 |
-
console.log(chalk.white(` - ${successCount *
|
| 379 |
console.log(chalk.white(` - Dossier de sortie : ${SVGS_DIR}`));
|
| 380 |
console.log(chalk.white(` - Manifest : ${manifestPath}`));
|
| 381 |
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
/**
|
| 63 |
+
* Génère un SVG de la lettre A à partir d'une police (pour le sprite d'affichage)
|
| 64 |
*/
|
| 65 |
async function generateLetterASVG(fontPath, fontFamily) {
|
| 66 |
try {
|
|
|
|
| 133 |
}
|
| 134 |
}
|
| 135 |
|
| 136 |
+
/**
|
| 137 |
+
* Génère un SVG multi-glyphes optimisé pour CLIP embedding.
|
| 138 |
+
*
|
| 139 |
+
* Rend "Hamburgefons" sur 2 lignes dans un carré 224×224, capturant :
|
| 140 |
+
* - Majuscules/minuscules, ascenders (b,f,h), descenders (g),
|
| 141 |
+
* courbes (o,e,s), diagonales (H,n), empattements, contraste de traits
|
| 142 |
+
*/
|
| 143 |
+
async function generateEmbeddingSVG(fontPath, fontFamily) {
|
| 144 |
+
const EMBED_LINES = ['Hamburge', 'fonstiv'];
|
| 145 |
+
const SVG_SIZE = 224;
|
| 146 |
+
const PADDING = 12;
|
| 147 |
+
|
| 148 |
+
try {
|
| 149 |
+
const fontBuffer = await fs.readFile(fontPath);
|
| 150 |
+
const font = opentype.parse(fontBuffer.buffer);
|
| 151 |
+
|
| 152 |
+
const lineCount = EMBED_LINES.length;
|
| 153 |
+
const availableHeight = SVG_SIZE - PADDING * 2;
|
| 154 |
+
const lineHeight = availableHeight / lineCount;
|
| 155 |
+
const fontSize = Math.floor(lineHeight * 0.75);
|
| 156 |
+
|
| 157 |
+
let allPathsData = '';
|
| 158 |
+
|
| 159 |
+
for (let lineIdx = 0; lineIdx < lineCount; lineIdx++) {
|
| 160 |
+
const text = EMBED_LINES[lineIdx];
|
| 161 |
+
const textPath = font.getPath(text, 0, 0, fontSize);
|
| 162 |
+
const bbox = textPath.getBoundingBox();
|
| 163 |
+
|
| 164 |
+
if (!bbox || bbox.x2 - bbox.x1 <= 0 || bbox.y2 - bbox.y1 <= 0) continue;
|
| 165 |
+
|
| 166 |
+
const textWidth = bbox.x2 - bbox.x1;
|
| 167 |
+
const textHeight = bbox.y2 - bbox.y1;
|
| 168 |
+
|
| 169 |
+
const scale = Math.min(
|
| 170 |
+
(SVG_SIZE - PADDING * 2) / textWidth,
|
| 171 |
+
lineHeight * 0.85 / textHeight,
|
| 172 |
+
1.0
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
const scaledWidth = textWidth * scale;
|
| 176 |
+
const offsetX = (SVG_SIZE - scaledWidth) / 2 - bbox.x1 * scale;
|
| 177 |
+
const lineY = PADDING + lineIdx * lineHeight + lineHeight / 2;
|
| 178 |
+
const offsetY = lineY - (bbox.y1 + textHeight / 2) * scale;
|
| 179 |
+
|
| 180 |
+
if (scale !== 1.0) {
|
| 181 |
+
const adjusted = font.getPath(text, 0, 0, fontSize * scale);
|
| 182 |
+
const adjBbox = adjusted.getBoundingBox();
|
| 183 |
+
const adjW = adjBbox.x2 - adjBbox.x1;
|
| 184 |
+
const adjH = adjBbox.y2 - adjBbox.y1;
|
| 185 |
+
const ox = (SVG_SIZE - adjW) / 2 - adjBbox.x1;
|
| 186 |
+
const oy = lineY - (adjBbox.y1 + adjH / 2);
|
| 187 |
+
const final = font.getPath(text, ox, oy, fontSize * scale);
|
| 188 |
+
allPathsData += ` ${final.toPathData(2)}`;
|
| 189 |
+
} else {
|
| 190 |
+
const final = font.getPath(text, offsetX, offsetY, fontSize);
|
| 191 |
+
allPathsData += ` ${final.toPathData(2)}`;
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
allPathsData = allPathsData.trim();
|
| 196 |
+
if (!allPathsData || allPathsData.length < 10) {
|
| 197 |
+
throw new Error('Embedding SVG paths trop simples ou vides');
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SVG_SIZE} ${SVG_SIZE}" width="${SVG_SIZE}" height="${SVG_SIZE}">
|
| 201 |
+
<rect width="${SVG_SIZE}" height="${SVG_SIZE}" fill="white"/>
|
| 202 |
+
<path d="${allPathsData}" fill="black"/>
|
| 203 |
+
</svg>`;
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
svg,
|
| 207 |
+
width: SVG_SIZE,
|
| 208 |
+
height: SVG_SIZE,
|
| 209 |
+
lines: EMBED_LINES,
|
| 210 |
+
fontMetrics: {
|
| 211 |
+
unitsPerEm: font.unitsPerEm,
|
| 212 |
+
ascender: font.ascender,
|
| 213 |
+
descender: font.descender
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
} catch (error) {
|
| 218 |
+
console.error(`❌ Error generating embedding SVG for ${fontFamily}:`, error.message);
|
| 219 |
+
return null;
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
/**
|
| 224 |
* Génère un SVG de phrase à partir d'une police (vectorisé)
|
| 225 |
*/
|
|
|
|
| 311 |
|
| 312 |
const fontPath = path.join(fontDir, ttfFile);
|
| 313 |
|
| 314 |
+
// Générer le SVG de la lettre A (pour l'affichage sprite)
|
| 315 |
const letterResult = await generateLetterASVG(fontPath, fontData.family);
|
| 316 |
if (!letterResult) {
|
| 317 |
throw new Error('Failed de génération du SVG de la lettre A');
|
|
|
|
| 320 |
// Sauvegarder le SVG de la lettre A
|
| 321 |
const letterSvgPath = path.join(SVGS_DIR, `${fontId}_a.svg`);
|
| 322 |
await fs.writeFile(letterSvgPath, letterResult.svg, 'utf-8');
|
| 323 |
+
|
| 324 |
+
// Générer le SVG multi-glyphes pour CLIP embedding (224×224, "Hamburgefons")
|
| 325 |
+
const embedResult = await generateEmbeddingSVG(fontPath, fontData.family);
|
| 326 |
+
if (!embedResult) {
|
| 327 |
+
throw new Error('Failed de génération du SVG embedding multi-glyphes');
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
const embedSvgPath = path.join(SVGS_DIR, `${fontId}_embed.svg`);
|
| 331 |
+
await fs.writeFile(embedSvgPath, embedResult.svg, 'utf-8');
|
| 332 |
|
| 333 |
// Générer le SVG de la phrase
|
| 334 |
const sentenceResult = await generateSentenceSVG(fontPath, fontData.family);
|
|
|
|
| 345 |
fontId,
|
| 346 |
fontFamily: fontData.family,
|
| 347 |
letterSvg: letterSvgPath,
|
| 348 |
+
embedSvg: embedSvgPath,
|
| 349 |
sentenceSvg: sentenceSvgPath,
|
| 350 |
letterDimensions: {
|
| 351 |
width: letterResult.width,
|
| 352 |
height: letterResult.height
|
| 353 |
},
|
| 354 |
+
embedDimensions: {
|
| 355 |
+
width: embedResult.width,
|
| 356 |
+
height: embedResult.height
|
| 357 |
+
},
|
| 358 |
sentenceDimensions: {
|
| 359 |
width: sentenceResult.width,
|
| 360 |
height: sentenceResult.height,
|
|
|
|
| 411 |
console.log(chalk.magenta(`\n🔤 [${i + 1}/${fontIds.length}] Traitement de "${fontData.family}" (${fontId})`));
|
| 412 |
|
| 413 |
// Démarrer la barre de progression
|
| 414 |
+
progressBar.start(3, 0, { fontName: fontData.family });
|
| 415 |
|
| 416 |
const result = await processFontFamily(fontId, fontData, i, fontIds.length);
|
| 417 |
|
| 418 |
+
progressBar.update(3, { fontName: fontData.family });
|
| 419 |
progressBar.stop();
|
| 420 |
|
| 421 |
results.push(result);
|
|
|
|
| 424 |
successCount++;
|
| 425 |
console.log(chalk.green(`✅ SVGs générés pour "${fontData.family}"`));
|
| 426 |
console.log(chalk.blue(` - Lettre A: ${result.letterDimensions.width}x${result.letterDimensions.height}`));
|
| 427 |
+
console.log(chalk.blue(` - Embed: ${result.embedDimensions.width}x${result.embedDimensions.height} (multi-glyphes pour CLIP)`));
|
| 428 |
console.log(chalk.blue(` - Phrase: ${result.sentenceDimensions.width}x${result.sentenceDimensions.height} (largeur mesurée: ${result.sentenceDimensions.measuredWidth.toFixed(1)}px)`));
|
| 429 |
} else {
|
| 430 |
errorCount++;
|
|
|
|
| 477 |
console.log(chalk.cyan('📊 Summary :'));
|
| 478 |
console.log(chalk.white(` - ${successCount} familles traitées avec succès`));
|
| 479 |
console.log(chalk.white(` - ${errorCount} erreurs`));
|
| 480 |
+
console.log(chalk.white(` - ${successCount * 3} fichiers SVG générés (lettre A + embed + phrase)`));
|
| 481 |
console.log(chalk.white(` - Dossier de sortie : ${SVGS_DIR}`));
|
| 482 |
console.log(chalk.white(` - Manifest : ${manifestPath}`));
|
| 483 |
|
src/typography/new-pipe/3-generate-pngs.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename);
|
|
| 13 |
// Configuration
|
| 14 |
const SVGS_DIR = path.join(__dirname, 'output', 'svgs');
|
| 15 |
const PNGS_DIR = path.join(__dirname, 'output', 'pngs');
|
| 16 |
-
const PNG_SIZE =
|
| 17 |
|
| 18 |
// Configuration de la barre de progression
|
| 19 |
const progressBar = new cliProgress.SingleBar({
|
|
@@ -168,17 +168,16 @@ async function convertSvgToPng(svgPath, pngPath, fontFamily) {
|
|
| 168 |
}
|
| 169 |
|
| 170 |
/**
|
| 171 |
-
* Traite un fichier SVG
|
|
|
|
| 172 |
*/
|
| 173 |
-
async function
|
| 174 |
const svgPath = path.join(SVGS_DIR, svgFile);
|
| 175 |
|
| 176 |
-
// Créer le nom de fichier PNG
|
| 177 |
const pngFile = svgFile.replace('.svg', '.png');
|
| 178 |
const pngPath = path.join(PNGS_DIR, pngFile);
|
| 179 |
|
| 180 |
-
|
| 181 |
-
const fontId = svgFile.replace('_a.svg', '');
|
| 182 |
const fontFamily = fontId.replace(/-/g, ' ');
|
| 183 |
|
| 184 |
try {
|
|
@@ -221,36 +220,41 @@ async function processLetterASVG(svgFile, currentIndex, totalFiles) {
|
|
| 221 |
*/
|
| 222 |
async function main() {
|
| 223 |
try {
|
| 224 |
-
console.log(chalk.blue.bold(`🖼️ Génération des PNG ${PNG_SIZE}x${PNG_SIZE} pour
|
| 225 |
|
| 226 |
// Create directory PNG si nécessaire
|
| 227 |
await fs.mkdir(PNGS_DIR, { recursive: true });
|
| 228 |
console.log(chalk.green(`📁 Directory created : ${PNGS_DIR}`));
|
| 229 |
|
| 230 |
-
// Lire tous les fichiers SVG
|
| 231 |
const svgFiles = await fs.readdir(SVGS_DIR);
|
|
|
|
| 232 |
const letterASvgFiles = svgFiles.filter(file => file.endsWith('_a.svg'));
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
|
| 238 |
-
console.log(chalk.cyan(`📁 ${
|
|
|
|
| 239 |
|
| 240 |
const results = [];
|
| 241 |
let successCount = 0;
|
| 242 |
let errorCount = 0;
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
const svgFile = letterASvgFiles[i];
|
| 247 |
|
| 248 |
-
console.log(chalk.magenta(`\n🔤 [${i + 1}/${
|
| 249 |
|
| 250 |
-
// Démarrer la barre de progression
|
| 251 |
progressBar.start(1, 0, { fontName: svgFile });
|
| 252 |
|
| 253 |
-
const result = await
|
| 254 |
|
| 255 |
progressBar.update(1, { fontName: svgFile });
|
| 256 |
progressBar.stop();
|
|
@@ -265,9 +269,8 @@ async function main() {
|
|
| 265 |
console.log(chalk.red(`❌ PNG rejeté : ${result.fontFamily} - ${result.error}`));
|
| 266 |
}
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
console.log(chalk.blue(`📈 Progrès global : ${i + 1}/${letterASvgFiles.length} fichiers (${progress.toFixed(1)}%)`));
|
| 271 |
console.log(chalk.blue(`📊 Success: ${successCount}, Erreurs: ${errorCount}\n`));
|
| 272 |
}
|
| 273 |
|
|
@@ -275,7 +278,8 @@ async function main() {
|
|
| 275 |
console.log(chalk.cyan('📊 Summary :'));
|
| 276 |
console.log(chalk.white(` - ${successCount} PNG générés avec succès`));
|
| 277 |
console.log(chalk.white(` - ${errorCount} erreurs`));
|
| 278 |
-
console.log(chalk.white(` - ${
|
|
|
|
| 279 |
console.log(chalk.white(` - Dossier de sortie : ${PNGS_DIR}`));
|
| 280 |
|
| 281 |
if (errorCount > 0) {
|
|
|
|
| 13 |
// Configuration
|
| 14 |
const SVGS_DIR = path.join(__dirname, 'output', 'svgs');
|
| 15 |
const PNGS_DIR = path.join(__dirname, 'output', 'pngs');
|
| 16 |
+
const PNG_SIZE = 224; // Native CLIP ViT-B/32 input resolution
|
| 17 |
|
| 18 |
// Configuration de la barre de progression
|
| 19 |
const progressBar = new cliProgress.SingleBar({
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
/**
|
| 171 |
+
* Traite un fichier SVG pour conversion en PNG.
|
| 172 |
+
* Accepte les SVG _embed.svg (multi-glyphes pour CLIP) ou _a.svg (fallback).
|
| 173 |
*/
|
| 174 |
+
async function processEmbedSVG(svgFile, currentIndex, totalFiles) {
|
| 175 |
const svgPath = path.join(SVGS_DIR, svgFile);
|
| 176 |
|
|
|
|
| 177 |
const pngFile = svgFile.replace('.svg', '.png');
|
| 178 |
const pngPath = path.join(PNGS_DIR, pngFile);
|
| 179 |
|
| 180 |
+
const fontId = svgFile.replace('_embed.svg', '').replace('_a.svg', '');
|
|
|
|
| 181 |
const fontFamily = fontId.replace(/-/g, ' ');
|
| 182 |
|
| 183 |
try {
|
|
|
|
| 220 |
*/
|
| 221 |
async function main() {
|
| 222 |
try {
|
| 223 |
+
console.log(chalk.blue.bold(`🖼️ Génération des PNG ${PNG_SIZE}x${PNG_SIZE} multi-glyphes pour CLIP...\n`));
|
| 224 |
|
| 225 |
// Create directory PNG si nécessaire
|
| 226 |
await fs.mkdir(PNGS_DIR, { recursive: true });
|
| 227 |
console.log(chalk.green(`📁 Directory created : ${PNGS_DIR}`));
|
| 228 |
|
| 229 |
+
// Lire tous les fichiers SVG — préférer les _embed.svg (multi-glyphes), fallback sur _a.svg
|
| 230 |
const svgFiles = await fs.readdir(SVGS_DIR);
|
| 231 |
+
const embedSvgFiles = svgFiles.filter(file => file.endsWith('_embed.svg'));
|
| 232 |
const letterASvgFiles = svgFiles.filter(file => file.endsWith('_a.svg'));
|
| 233 |
|
| 234 |
+
// Construire la liste: utiliser _embed quand disponible, sinon _a
|
| 235 |
+
const embedIds = new Set(embedSvgFiles.map(f => f.replace('_embed.svg', '')));
|
| 236 |
+
const fallbackFiles = letterASvgFiles.filter(f => !embedIds.has(f.replace('_a.svg', '')));
|
| 237 |
+
const targetSvgFiles = [...embedSvgFiles, ...fallbackFiles];
|
| 238 |
+
|
| 239 |
+
if (targetSvgFiles.length === 0) {
|
| 240 |
+
throw new Error('Aucun fichier SVG trouvé dans ' + SVGS_DIR);
|
| 241 |
}
|
| 242 |
|
| 243 |
+
console.log(chalk.cyan(`📁 ${embedSvgFiles.length} SVG embed (multi-glyphes) + ${fallbackFiles.length} fallback (lettre A)`));
|
| 244 |
+
console.log(chalk.cyan(`📁 ${targetSvgFiles.length} fichiers SVG à traiter\n`));
|
| 245 |
|
| 246 |
const results = [];
|
| 247 |
let successCount = 0;
|
| 248 |
let errorCount = 0;
|
| 249 |
|
| 250 |
+
for (let i = 0; i < targetSvgFiles.length; i++) {
|
| 251 |
+
const svgFile = targetSvgFiles[i];
|
|
|
|
| 252 |
|
| 253 |
+
console.log(chalk.magenta(`\n🔤 [${i + 1}/${targetSvgFiles.length}] Traitement de "${svgFile}"`));
|
| 254 |
|
|
|
|
| 255 |
progressBar.start(1, 0, { fontName: svgFile });
|
| 256 |
|
| 257 |
+
const result = await processEmbedSVG(svgFile, i, targetSvgFiles.length);
|
| 258 |
|
| 259 |
progressBar.update(1, { fontName: svgFile });
|
| 260 |
progressBar.stop();
|
|
|
|
| 269 |
console.log(chalk.red(`❌ PNG rejeté : ${result.fontFamily} - ${result.error}`));
|
| 270 |
}
|
| 271 |
|
| 272 |
+
const progress = ((i + 1) / targetSvgFiles.length) * 100;
|
| 273 |
+
console.log(chalk.blue(`📈 Progrès global : ${i + 1}/${targetSvgFiles.length} fichiers (${progress.toFixed(1)}%)`));
|
|
|
|
| 274 |
console.log(chalk.blue(`📊 Success: ${successCount}, Erreurs: ${errorCount}\n`));
|
| 275 |
}
|
| 276 |
|
|
|
|
| 278 |
console.log(chalk.cyan('📊 Summary :'));
|
| 279 |
console.log(chalk.white(` - ${successCount} PNG générés avec succès`));
|
| 280 |
console.log(chalk.white(` - ${errorCount} erreurs`));
|
| 281 |
+
console.log(chalk.white(` - ${targetSvgFiles.length} fichiers traités`));
|
| 282 |
+
console.log(chalk.white(` - Résolution: ${PNG_SIZE}x${PNG_SIZE} (résolution native CLIP)`));
|
| 283 |
console.log(chalk.white(` - Dossier de sortie : ${PNGS_DIR}`));
|
| 284 |
|
| 285 |
if (errorCount > 0) {
|
src/typography/new-pipe/4-generate-embeddings.mjs
CHANGED
|
@@ -102,7 +102,7 @@ async function generateCLIPEmbedding(pngPath, fontId) {
|
|
| 102 |
* Extrait les informations de police à partir du nom de fichier et du fichier d'index
|
| 103 |
*/
|
| 104 |
function extractFontInfoFromFilename(filename, fontIndexData) {
|
| 105 |
-
const fontId = filename.replace('.png', '').replace('_a', '');
|
| 106 |
const fontData = fontIndexData[fontId];
|
| 107 |
|
| 108 |
if (!fontData) {
|
|
@@ -150,10 +150,15 @@ async function loadAllFontDataWithEmbeddings() {
|
|
| 150 |
const fontIndexData = JSON.parse(await fs.readFile(FONT_INDEX_PATH, 'utf8'));
|
| 151 |
console.log(chalk.green(`✅ Index chargé: ${Object.keys(fontIndexData).length} polices`));
|
| 152 |
|
| 153 |
-
// Trouver
|
| 154 |
-
// Note: On pourrait utiliser _sentence.png au lieu de _a.png pour plus d'info
|
| 155 |
const files = await fs.readdir(PNGS_DIR);
|
| 156 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
if (pngFiles.length === 0) {
|
| 159 |
throw new Error(`Aucun fichier PNG trouvé dans ${PNGS_DIR}`);
|
|
|
|
| 102 |
* Extrait les informations de police à partir du nom de fichier et du fichier d'index
|
| 103 |
*/
|
| 104 |
function extractFontInfoFromFilename(filename, fontIndexData) {
|
| 105 |
+
const fontId = filename.replace('.png', '').replace('_embed', '').replace('_a', '');
|
| 106 |
const fontData = fontIndexData[fontId];
|
| 107 |
|
| 108 |
if (!fontData) {
|
|
|
|
| 150 |
const fontIndexData = JSON.parse(await fs.readFile(FONT_INDEX_PATH, 'utf8'));
|
| 151 |
console.log(chalk.green(`✅ Index chargé: ${Object.keys(fontIndexData).length} polices`));
|
| 152 |
|
| 153 |
+
// Trouver les PNG d'embedding — préférer _embed.png (multi-glyphes), fallback _a.png
|
|
|
|
| 154 |
const files = await fs.readdir(PNGS_DIR);
|
| 155 |
+
const embedPngs = files.filter(file => file.endsWith('_embed.png'));
|
| 156 |
+
const letterPngs = files.filter(file => file.endsWith('_a.png'));
|
| 157 |
+
const embedIds = new Set(embedPngs.map(f => f.replace('_embed.png', '')));
|
| 158 |
+
const fallbackPngs = letterPngs.filter(f => !embedIds.has(f.replace('_a.png', '')));
|
| 159 |
+
const pngFiles = [...embedPngs, ...fallbackPngs];
|
| 160 |
+
|
| 161 |
+
console.log(chalk.cyan(`📊 ${embedPngs.length} multi-glyphes + ${fallbackPngs.length} fallback lettre A`));
|
| 162 |
|
| 163 |
if (pngFiles.length === 0) {
|
| 164 |
throw new Error(`Aucun fichier PNG trouvé dans ${PNGS_DIR}`);
|
src/typography/new-pipe/5-batch-umap.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
|
| 4 |
import path from 'path';
|
| 5 |
import fs from 'fs/promises';
|
| 6 |
import { UMAP } from 'umap-js';
|
| 7 |
-
import { Matrix } from 'ml-matrix';
|
| 8 |
import cliProgress from 'cli-progress';
|
| 9 |
import chalk from 'chalk';
|
| 10 |
|
|
@@ -189,7 +189,7 @@ function mergeFontFamilies(fontDataList, embeddingMatrices, config) {
|
|
| 189 |
}
|
| 190 |
|
| 191 |
/**
|
| 192 |
-
* Normalise les données
|
| 193 |
*/
|
| 194 |
function normalizeData(data) {
|
| 195 |
const matrix = new Matrix(data);
|
|
@@ -207,17 +207,115 @@ function normalizeData(data) {
|
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
| 210 |
-
return normalized
|
| 211 |
}
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
/**
|
| 215 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
*/
|
| 217 |
function generateUMAPWithConfig(config, embeddingMatrices) {
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
-
// Générateur aléatoire avec seed optionnel
|
| 221 |
let randomFn = Math.random;
|
| 222 |
if (config.randomSeed) {
|
| 223 |
let seed = config.randomSeed;
|
|
@@ -231,28 +329,30 @@ function generateUMAPWithConfig(config, embeddingMatrices) {
|
|
| 231 |
nComponents: 2,
|
| 232 |
nNeighbors: config.nNeighbors,
|
| 233 |
minDist: config.minDist,
|
| 234 |
-
metric: 'cosine',
|
| 235 |
random: randomFn
|
| 236 |
};
|
| 237 |
|
|
|
|
| 238 |
const umap = new UMAP(umapParams);
|
| 239 |
-
const embedding = umap.fit(
|
| 240 |
|
| 241 |
return embedding;
|
| 242 |
}
|
| 243 |
|
| 244 |
|
| 245 |
/**
|
| 246 |
-
* Sauvegarde les résultats
|
| 247 |
*/
|
| 248 |
-
async function saveResults(fontDataList, embedding, config) {
|
| 249 |
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
| 250 |
const resultPath = path.join(RESULTS_DIR, `${config.testName}_${timestamp}.json`);
|
| 251 |
|
| 252 |
const finalData = fontDataList.map((font, i) => ({
|
| 253 |
...font,
|
| 254 |
x: embedding[i][0],
|
| 255 |
-
y: embedding[i][1]
|
|
|
|
| 256 |
}));
|
| 257 |
|
| 258 |
const result = {
|
|
@@ -260,8 +360,10 @@ async function saveResults(fontDataList, embedding, config) {
|
|
| 260 |
metadata: {
|
| 261 |
generated_at: new Date().toISOString(),
|
| 262 |
total_fonts: finalData.length,
|
| 263 |
-
method: "
|
| 264 |
-
|
|
|
|
|
|
|
| 265 |
},
|
| 266 |
fonts: finalData
|
| 267 |
};
|
|
@@ -281,11 +383,14 @@ async function testConfiguration(config, fontDataList, embeddingMatrices) {
|
|
| 281 |
const { fontDataList: mergedFonts, embeddingMatrices: mergedEmbeddings } =
|
| 282 |
mergeFontFamilies(fontDataList, embeddingMatrices, config);
|
| 283 |
|
| 284 |
-
//
|
|
|
|
|
|
|
|
|
|
| 285 |
const embedding = generateUMAPWithConfig(config, mergedEmbeddings);
|
| 286 |
|
| 287 |
-
// Sauvegarder
|
| 288 |
-
const resultPath = await saveResults(mergedFonts, embedding, config);
|
| 289 |
|
| 290 |
const duration = (Date.now() - startTime) / 1000;
|
| 291 |
|
|
|
|
| 4 |
import path from 'path';
|
| 5 |
import fs from 'fs/promises';
|
| 6 |
import { UMAP } from 'umap-js';
|
| 7 |
+
import { Matrix, EVD } from 'ml-matrix';
|
| 8 |
import cliProgress from 'cli-progress';
|
| 9 |
import chalk from 'chalk';
|
| 10 |
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
/**
|
| 192 |
+
* Normalise les données (Z-score par colonne)
|
| 193 |
*/
|
| 194 |
function normalizeData(data) {
|
| 195 |
const matrix = new Matrix(data);
|
|
|
|
| 207 |
}
|
| 208 |
}
|
| 209 |
|
| 210 |
+
return normalized;
|
| 211 |
}
|
| 212 |
|
| 213 |
+
/**
|
| 214 |
+
* Réduction PCA: 512D → nComponents (défaut 50).
|
| 215 |
+
* Réduit le bruit, accélère le k-NN, et concentre la variance utile.
|
| 216 |
+
*/
|
| 217 |
+
function applyPCA(matrix, nComponents = 50) {
|
| 218 |
+
const rows = matrix.rows;
|
| 219 |
+
const cols = matrix.columns;
|
| 220 |
+
const target = Math.min(nComponents, cols, rows);
|
| 221 |
+
|
| 222 |
+
// Centrer les données (nécessaire pour PCA)
|
| 223 |
+
const means = matrix.mean('column');
|
| 224 |
+
const centered = matrix.clone();
|
| 225 |
+
for (let i = 0; i < rows; i++) {
|
| 226 |
+
for (let j = 0; j < cols; j++) {
|
| 227 |
+
centered.set(i, j, centered.get(i, j) - means[j]);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Covariance: (1/n) * X^T * X
|
| 232 |
+
const cov = centered.transpose().mmul(centered).div(rows - 1);
|
| 233 |
+
|
| 234 |
+
// Eigen decomposition
|
| 235 |
+
const evd = new EVD(cov);
|
| 236 |
+
const eigenvalues = evd.realEigenvalues;
|
| 237 |
+
const eigenvectors = evd.eigenvectorMatrix;
|
| 238 |
+
|
| 239 |
+
// Trier par valeur propre décroissante
|
| 240 |
+
const indices = eigenvalues
|
| 241 |
+
.map((val, idx) => ({ val, idx }))
|
| 242 |
+
.sort((a, b) => b.val - a.val)
|
| 243 |
+
.map(item => item.idx);
|
| 244 |
+
|
| 245 |
+
// Garder les top nComponents eigenvectors
|
| 246 |
+
const topIndices = indices.slice(0, target);
|
| 247 |
+
const projectionMatrix = new Matrix(cols, target);
|
| 248 |
+
for (let j = 0; j < target; j++) {
|
| 249 |
+
for (let i = 0; i < cols; i++) {
|
| 250 |
+
projectionMatrix.set(i, j, eigenvectors.get(i, topIndices[j]));
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// Projeter
|
| 255 |
+
const projected = centered.mmul(projectionMatrix);
|
| 256 |
+
|
| 257 |
+
// Variance expliquée
|
| 258 |
+
const totalVariance = eigenvalues.reduce((sum, v) => sum + Math.max(0, v), 0);
|
| 259 |
+
const explainedVariance = topIndices.reduce((sum, idx) => sum + Math.max(0, eigenvalues[idx]), 0);
|
| 260 |
+
const varianceRatio = totalVariance > 0 ? (explainedVariance / totalVariance * 100) : 0;
|
| 261 |
+
|
| 262 |
+
console.log(chalk.cyan(` 📐 PCA: ${cols}D → ${target}D (${varianceRatio.toFixed(1)}% variance conservée)`));
|
| 263 |
+
|
| 264 |
+
return projected.to2DArray();
|
| 265 |
+
}
|
| 266 |
|
| 267 |
/**
|
| 268 |
+
* Calcule la similarité cosine entre deux vecteurs
|
| 269 |
+
*/
|
| 270 |
+
function cosineSimilarity(a, b) {
|
| 271 |
+
let dot = 0, normA = 0, normB = 0;
|
| 272 |
+
for (let i = 0; i < a.length; i++) {
|
| 273 |
+
dot += a[i] * b[i];
|
| 274 |
+
normA += a[i] * a[i];
|
| 275 |
+
normB += b[i] * b[i];
|
| 276 |
+
}
|
| 277 |
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
| 278 |
+
return denom === 0 ? 0 : dot / denom;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* Pré-calcule les k plus proches voisins dans l'espace haute dimension.
|
| 283 |
+
* Utilise la distance cosine (1 - similarité) sur les embeddings originaux (avant PCA).
|
| 284 |
+
*/
|
| 285 |
+
function computeHighDimKNN(embeddingMatrices, k = 8) {
|
| 286 |
+
const n = embeddingMatrices.length;
|
| 287 |
+
console.log(chalk.cyan(` 🔍 Calcul k-NN (k=${k}) dans l'espace ${embeddingMatrices[0].length}D...`));
|
| 288 |
+
|
| 289 |
+
const neighbors = new Array(n);
|
| 290 |
+
|
| 291 |
+
for (let i = 0; i < n; i++) {
|
| 292 |
+
const distances = [];
|
| 293 |
+
for (let j = 0; j < n; j++) {
|
| 294 |
+
if (i === j) continue;
|
| 295 |
+
const sim = cosineSimilarity(embeddingMatrices[i], embeddingMatrices[j]);
|
| 296 |
+
distances.push({ index: j, distance: 1 - sim });
|
| 297 |
+
}
|
| 298 |
+
distances.sort((a, b) => a.distance - b.distance);
|
| 299 |
+
neighbors[i] = distances.slice(0, k).map(d => d.index);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
return neighbors;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// Configuration PCA
|
| 306 |
+
const PCA_COMPONENTS = 50;
|
| 307 |
+
const KNN_NEIGHBORS = 8;
|
| 308 |
+
|
| 309 |
+
/**
|
| 310 |
+
* Génère l'embedding UMAP avec une config donnée, incluant PCA et k-NN
|
| 311 |
*/
|
| 312 |
function generateUMAPWithConfig(config, embeddingMatrices) {
|
| 313 |
+
console.log(chalk.blue(` 🔄 Normalisation Z-score...`));
|
| 314 |
+
const normalizedMatrix = normalizeData(embeddingMatrices);
|
| 315 |
+
|
| 316 |
+
console.log(chalk.blue(` 🔄 Réduction PCA...`));
|
| 317 |
+
const pcaData = applyPCA(normalizedMatrix, PCA_COMPONENTS);
|
| 318 |
|
|
|
|
| 319 |
let randomFn = Math.random;
|
| 320 |
if (config.randomSeed) {
|
| 321 |
let seed = config.randomSeed;
|
|
|
|
| 329 |
nComponents: 2,
|
| 330 |
nNeighbors: config.nNeighbors,
|
| 331 |
minDist: config.minDist,
|
| 332 |
+
metric: 'cosine',
|
| 333 |
random: randomFn
|
| 334 |
};
|
| 335 |
|
| 336 |
+
console.log(chalk.blue(` 🔄 UMAP (n=${config.nNeighbors}, d=${config.minDist})...`));
|
| 337 |
const umap = new UMAP(umapParams);
|
| 338 |
+
const embedding = umap.fit(pcaData);
|
| 339 |
|
| 340 |
return embedding;
|
| 341 |
}
|
| 342 |
|
| 343 |
|
| 344 |
/**
|
| 345 |
+
* Sauvegarde les résultats avec les voisins pré-calculés
|
| 346 |
*/
|
| 347 |
+
async function saveResults(fontDataList, embedding, config, knnNeighbors) {
|
| 348 |
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
| 349 |
const resultPath = path.join(RESULTS_DIR, `${config.testName}_${timestamp}.json`);
|
| 350 |
|
| 351 |
const finalData = fontDataList.map((font, i) => ({
|
| 352 |
...font,
|
| 353 |
x: embedding[i][0],
|
| 354 |
+
y: embedding[i][1],
|
| 355 |
+
neighbors: knnNeighbors ? knnNeighbors[i].map(idx => fontDataList[idx].id) : []
|
| 356 |
}));
|
| 357 |
|
| 358 |
const result = {
|
|
|
|
| 360 |
metadata: {
|
| 361 |
generated_at: new Date().toISOString(),
|
| 362 |
total_fonts: finalData.length,
|
| 363 |
+
method: "umap_from_clip_embeddings_enhanced",
|
| 364 |
+
pca_components: PCA_COMPONENTS,
|
| 365 |
+
knn_neighbors: KNN_NEIGHBORS,
|
| 366 |
+
note: "Multi-glyph CLIP embeddings, PCA-reduced, with high-dim k-NN pre-computed"
|
| 367 |
},
|
| 368 |
fonts: finalData
|
| 369 |
};
|
|
|
|
| 383 |
const { fontDataList: mergedFonts, embeddingMatrices: mergedEmbeddings } =
|
| 384 |
mergeFontFamilies(fontDataList, embeddingMatrices, config);
|
| 385 |
|
| 386 |
+
// Pré-calculer k-NN dans l'espace haute dimension (avant PCA/UMAP)
|
| 387 |
+
const knnNeighbors = computeHighDimKNN(mergedEmbeddings, KNN_NEIGHBORS);
|
| 388 |
+
|
| 389 |
+
// Générer UMAP (avec PCA)
|
| 390 |
const embedding = generateUMAPWithConfig(config, mergedEmbeddings);
|
| 391 |
|
| 392 |
+
// Sauvegarder avec les voisins
|
| 393 |
+
const resultPath = await saveResults(mergedFonts, embedding, config, knnNeighbors);
|
| 394 |
|
| 395 |
const duration = (Date.now() - startTime) / 1000;
|
| 396 |
|
src/typography/new-pipe/8-deploy-to-prod.mjs
CHANGED
|
@@ -21,9 +21,13 @@ async function deployConfig(configName) {
|
|
| 21 |
try {
|
| 22 |
console.log(chalk.blue.bold(`🚀 Déploiement de la config "${configName}" en production\n`));
|
| 23 |
|
| 24 |
-
// 1. Trouver le fichier de résultat
|
| 25 |
const files = await fs.readdir(RESULTS_DIR);
|
| 26 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
if (!resultFile) {
|
| 29 |
console.error(chalk.red(`❌ Config "${configName}" non trouvée dans les résultats`));
|
|
|
|
| 21 |
try {
|
| 22 |
console.log(chalk.blue.bold(`🚀 Déploiement de la config "${configName}" en production\n`));
|
| 23 |
|
| 24 |
+
// 1. Trouver le fichier de résultat le plus récent
|
| 25 |
const files = await fs.readdir(RESULTS_DIR);
|
| 26 |
+
const matchingFiles = files
|
| 27 |
+
.filter(f => f.startsWith(configName + '_'))
|
| 28 |
+
.sort()
|
| 29 |
+
.reverse();
|
| 30 |
+
const resultFile = matchingFiles[0];
|
| 31 |
|
| 32 |
if (!resultFile) {
|
| 33 |
console.error(chalk.red(`❌ Config "${configName}" non trouvée dans les résultats`));
|
src/typography/new-pipe/python-pipeline/run_fontclip.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
FontMap + FontCLIP pipeline.
|
| 4 |
+
|
| 5 |
+
Uses the pre-trained FontCLIP checkpoint to generate typography-aware
|
| 6 |
+
embeddings, then applies PCA + spectral UMAP + high-dim k-NN.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
cd src/typography/new-pipe/python-pipeline
|
| 10 |
+
source .venv/bin/activate
|
| 11 |
+
python run_fontclip.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
from PIL import Image
|
| 22 |
+
from sklearn.decomposition import PCA
|
| 23 |
+
from sklearn.neighbors import NearestNeighbors
|
| 24 |
+
from torchvision.transforms import Compose, Resize, CenterCrop, ToTensor, Normalize
|
| 25 |
+
from tqdm import tqdm
|
| 26 |
+
|
| 27 |
+
SCRIPT_DIR = Path(__file__).parent.resolve()
|
| 28 |
+
FONTCLIP_DIR = SCRIPT_DIR / "fontclip_workspace" / "FontCLIP"
|
| 29 |
+
CHECKPOINT_PATH = FONTCLIP_DIR / "model_checkpoints" / "model.pt"
|
| 30 |
+
|
| 31 |
+
PIPE_DIR = SCRIPT_DIR.parent
|
| 32 |
+
PNGS_DIR = PIPE_DIR / "output" / "pngs"
|
| 33 |
+
FONT_INDEX_PATH = PIPE_DIR / "input" / "font-index.json"
|
| 34 |
+
OUTPUT_DIR = PIPE_DIR / "output" / "data"
|
| 35 |
+
RESULTS_DIR = PIPE_DIR / "batch-testing" / "results"
|
| 36 |
+
|
| 37 |
+
PCA_COMPONENTS = 50
|
| 38 |
+
UMAP_NEIGHBORS = 12
|
| 39 |
+
UMAP_MIN_DIST = 1.0
|
| 40 |
+
KNN_K = 8
|
| 41 |
+
RANDOM_STATE = 42
|
| 42 |
+
ENABLE_FONT_FUSION = True
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from torchvision.transforms import InterpolationMode
|
| 46 |
+
BICUBIC = InterpolationMode.BICUBIC
|
| 47 |
+
except ImportError:
|
| 48 |
+
BICUBIC = Image.BICUBIC
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_preprocess(input_resolution=224):
|
| 52 |
+
return Compose([
|
| 53 |
+
Resize(input_resolution, interpolation=BICUBIC),
|
| 54 |
+
CenterCrop(input_resolution),
|
| 55 |
+
lambda img: img.convert("RGB"),
|
| 56 |
+
ToTensor(),
|
| 57 |
+
Normalize(
|
| 58 |
+
(0.48145466, 0.4578275, 0.40821073),
|
| 59 |
+
(0.26862954, 0.26130258, 0.27577711),
|
| 60 |
+
),
|
| 61 |
+
])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def load_fontclip_model(device="cpu"):
|
| 65 |
+
"""Load FontCLIP by importing model code and applying the checkpoint."""
|
| 66 |
+
saved_cwd = os.getcwd()
|
| 67 |
+
os.chdir(str(FONTCLIP_DIR))
|
| 68 |
+
sys.path.insert(0, str(FONTCLIP_DIR))
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
from models.init_model import load_model
|
| 72 |
+
from models.lora import LoRAConfig
|
| 73 |
+
|
| 74 |
+
lora_config_text = LoRAConfig(
|
| 75 |
+
r=256, alpha=1024.0, bias=False, learnable_alpha=False,
|
| 76 |
+
apply_q=True, apply_k=True, apply_v=True, apply_out=True,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
print("🔄 Loading FontCLIP ViT-B/32 (with LoRA text adapter)...")
|
| 80 |
+
model = load_model(
|
| 81 |
+
checkpoint_path=str(CHECKPOINT_PATH),
|
| 82 |
+
requires_grad=False,
|
| 83 |
+
device=device,
|
| 84 |
+
model_name="ViT-B/32",
|
| 85 |
+
use_lora_text=True,
|
| 86 |
+
lora_config_text=lora_config_text,
|
| 87 |
+
)
|
| 88 |
+
finally:
|
| 89 |
+
os.chdir(saved_cwd)
|
| 90 |
+
|
| 91 |
+
model.eval()
|
| 92 |
+
input_resolution = model.visual.input_resolution
|
| 93 |
+
preprocess = get_preprocess(input_resolution)
|
| 94 |
+
print(f"✅ FontCLIP loaded (input: {input_resolution}×{input_resolution}, device: {device})")
|
| 95 |
+
return model, preprocess
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def generate_embeddings(model, preprocess, device="cpu"):
|
| 99 |
+
"""Generate FontCLIP embeddings for all PNGs."""
|
| 100 |
+
png_files = sorted(PNGS_DIR.glob("*_embed.png"))
|
| 101 |
+
if not png_files:
|
| 102 |
+
png_files = sorted(PNGS_DIR.glob("*_a.png"))
|
| 103 |
+
print(f"⚠️ No _embed.png, falling back to _a.png ({len(png_files)})")
|
| 104 |
+
else:
|
| 105 |
+
print(f"📁 Found {len(png_files)} multi-glyph embed PNGs")
|
| 106 |
+
|
| 107 |
+
font_ids = []
|
| 108 |
+
embeddings = []
|
| 109 |
+
|
| 110 |
+
for png_path in tqdm(png_files, desc="FontCLIP embeddings"):
|
| 111 |
+
font_id = png_path.stem.replace("_embed", "").replace("_a", "")
|
| 112 |
+
font_ids.append(font_id)
|
| 113 |
+
|
| 114 |
+
image = preprocess(Image.open(png_path)).unsqueeze(0).to(device)
|
| 115 |
+
|
| 116 |
+
with torch.no_grad():
|
| 117 |
+
feat = model.encode_image(image)
|
| 118 |
+
feat = feat / feat.norm(dim=-1, keepdim=True)
|
| 119 |
+
|
| 120 |
+
embeddings.append(feat.cpu().numpy().squeeze().astype(np.float32))
|
| 121 |
+
|
| 122 |
+
embeddings = np.array(embeddings)
|
| 123 |
+
print(f"✅ {len(embeddings)} embeddings ({embeddings.shape[1]}D)")
|
| 124 |
+
return font_ids, embeddings
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def extract_fusion_prefix(font_id, font_meta=None):
|
| 128 |
+
"""Extract family prefix for fusion (mirrors JS extractFusionPrefix)."""
|
| 129 |
+
parts = font_id.split('-')
|
| 130 |
+
if len(parts) <= 1:
|
| 131 |
+
return font_id
|
| 132 |
+
|
| 133 |
+
if font_meta:
|
| 134 |
+
subsets = font_meta.get("subsets", [])
|
| 135 |
+
common = {'latin', 'latin-ext', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext'}
|
| 136 |
+
for subset in subsets:
|
| 137 |
+
if subset not in common and subset in font_id:
|
| 138 |
+
base = font_id.replace(f'-{subset}', '').replace(subset, '')
|
| 139 |
+
if base and base != font_id:
|
| 140 |
+
return base
|
| 141 |
+
|
| 142 |
+
special_cases = {
|
| 143 |
+
'baloo': ['baloo-2', 'baloo-bhai-2', 'baloo-bhaijaan-2', 'baloo-bhaina-2',
|
| 144 |
+
'baloo-chettan-2', 'baloo-da-2', 'baloo-paaji-2', 'baloo-tamma-2',
|
| 145 |
+
'baloo-tammudu-2', 'baloo-thambi-2'],
|
| 146 |
+
'ibm-plex': ['ibm-plex'],
|
| 147 |
+
'playwrite': ['playwrite'],
|
| 148 |
+
}
|
| 149 |
+
for family_prefix, patterns in special_cases.items():
|
| 150 |
+
for pattern in patterns:
|
| 151 |
+
if font_id.startswith(pattern):
|
| 152 |
+
return family_prefix
|
| 153 |
+
|
| 154 |
+
if font_id.startswith('noto-serif-'):
|
| 155 |
+
return 'noto-serif'
|
| 156 |
+
if font_id.startswith('noto-'):
|
| 157 |
+
return 'noto'
|
| 158 |
+
|
| 159 |
+
second = parts[1]
|
| 160 |
+
if second in ('sans', 'serif', 'plex'):
|
| 161 |
+
return '-'.join(parts[:2])
|
| 162 |
+
|
| 163 |
+
return parts[0]
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def merge_font_families(font_ids, embeddings, font_index):
|
| 167 |
+
"""Group font variants by family prefix and pick one representative."""
|
| 168 |
+
prefix_groups = {}
|
| 169 |
+
|
| 170 |
+
for i, fid in enumerate(font_ids):
|
| 171 |
+
meta = font_index.get(fid, {})
|
| 172 |
+
prefix = extract_fusion_prefix(fid, meta)
|
| 173 |
+
if prefix not in prefix_groups:
|
| 174 |
+
prefix_groups[prefix] = []
|
| 175 |
+
prefix_groups[prefix].append(i)
|
| 176 |
+
|
| 177 |
+
representatives = {
|
| 178 |
+
'noto': 'noto-sans-arabic',
|
| 179 |
+
'noto-serif': 'noto-serif-latin',
|
| 180 |
+
'ibm-plex': 'ibm-plex-sans',
|
| 181 |
+
'baloo': 'baloo-2',
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
merged_ids = []
|
| 185 |
+
merged_embeddings = []
|
| 186 |
+
merged_image_names = []
|
| 187 |
+
|
| 188 |
+
for prefix, indices in prefix_groups.items():
|
| 189 |
+
if len(indices) > 1:
|
| 190 |
+
rep_idx = indices[0]
|
| 191 |
+
if prefix in representatives:
|
| 192 |
+
for idx in indices:
|
| 193 |
+
if font_ids[idx] == representatives[prefix]:
|
| 194 |
+
rep_idx = idx
|
| 195 |
+
break
|
| 196 |
+
merged_ids.append(prefix)
|
| 197 |
+
merged_embeddings.append(embeddings[rep_idx])
|
| 198 |
+
merged_image_names.append(font_ids[rep_idx])
|
| 199 |
+
else:
|
| 200 |
+
idx = indices[0]
|
| 201 |
+
merged_ids.append(font_ids[idx])
|
| 202 |
+
merged_embeddings.append(embeddings[idx])
|
| 203 |
+
merged_image_names.append(font_ids[idx])
|
| 204 |
+
|
| 205 |
+
merged_embeddings = np.array(merged_embeddings)
|
| 206 |
+
print(f"🔗 Font fusion: {len(font_ids)} → {len(merged_ids)} fonts ({len(font_ids) - len(merged_ids)} merged)")
|
| 207 |
+
return merged_ids, merged_embeddings, merged_image_names
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def run_pca_umap(embeddings):
|
| 211 |
+
import umap
|
| 212 |
+
|
| 213 |
+
print(f"\n📐 PCA: {embeddings.shape[1]}D → {PCA_COMPONENTS}D")
|
| 214 |
+
pca = PCA(n_components=PCA_COMPONENTS, random_state=RANDOM_STATE)
|
| 215 |
+
pca_result = pca.fit_transform(embeddings)
|
| 216 |
+
var = pca.explained_variance_ratio_.sum() * 100
|
| 217 |
+
print(f" Variance conservée: {var:.1f}%")
|
| 218 |
+
|
| 219 |
+
print(f"🗺️ UMAP: {PCA_COMPONENTS}D → 2D (spectral init, n={UMAP_NEIGHBORS}, d={UMAP_MIN_DIST})")
|
| 220 |
+
reducer = umap.UMAP(
|
| 221 |
+
n_components=2,
|
| 222 |
+
n_neighbors=UMAP_NEIGHBORS,
|
| 223 |
+
min_dist=UMAP_MIN_DIST,
|
| 224 |
+
metric="cosine",
|
| 225 |
+
init="spectral",
|
| 226 |
+
random_state=RANDOM_STATE,
|
| 227 |
+
verbose=True,
|
| 228 |
+
)
|
| 229 |
+
coords = reducer.fit_transform(pca_result)
|
| 230 |
+
return coords, var
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def compute_knn(embeddings):
|
| 234 |
+
print(f"\n🔍 k-NN (k={KNN_K}) in {embeddings.shape[1]}D...")
|
| 235 |
+
nn = NearestNeighbors(n_neighbors=KNN_K + 1, metric="cosine", algorithm="brute")
|
| 236 |
+
nn.fit(embeddings)
|
| 237 |
+
_, indices = nn.kneighbors(embeddings)
|
| 238 |
+
return indices[:, 1:]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def build_and_save(font_ids, coords, knn_indices, image_names=None):
|
| 242 |
+
font_index = {}
|
| 243 |
+
if FONT_INDEX_PATH.exists():
|
| 244 |
+
with open(FONT_INDEX_PATH) as f:
|
| 245 |
+
font_index = json.load(f)
|
| 246 |
+
|
| 247 |
+
fonts = []
|
| 248 |
+
for i, fid in enumerate(font_ids):
|
| 249 |
+
img_name = image_names[i] if image_names else fid
|
| 250 |
+
entry = {
|
| 251 |
+
"id": fid,
|
| 252 |
+
"name": fid,
|
| 253 |
+
"imageName": img_name,
|
| 254 |
+
"family": "sans-serif",
|
| 255 |
+
"x": float(coords[i, 0]),
|
| 256 |
+
"y": float(coords[i, 1]),
|
| 257 |
+
"neighbors": [font_ids[j] for j in knn_indices[i]],
|
| 258 |
+
}
|
| 259 |
+
lookup_id = img_name if img_name in font_index else fid
|
| 260 |
+
if lookup_id in font_index:
|
| 261 |
+
meta = font_index[lookup_id]
|
| 262 |
+
entry["family"] = meta.get("category", "sans-serif")
|
| 263 |
+
family = meta.get("family", lookup_id)
|
| 264 |
+
entry["google_fonts_url"] = f"https://fonts.google.com/specimen/{family.replace(' ', '+')}"
|
| 265 |
+
entry["weights"] = meta.get("weights", [])
|
| 266 |
+
entry["styles"] = meta.get("styles", [])
|
| 267 |
+
entry["subsets"] = meta.get("subsets", [])
|
| 268 |
+
fonts.append(entry)
|
| 269 |
+
|
| 270 |
+
result = {
|
| 271 |
+
"config": {
|
| 272 |
+
"nNeighbors": UMAP_NEIGHBORS,
|
| 273 |
+
"minDist": UMAP_MIN_DIST,
|
| 274 |
+
"metric": "cosine",
|
| 275 |
+
"enableFontFusion": ENABLE_FONT_FUSION,
|
| 276 |
+
"testName": "fontclip-spectral",
|
| 277 |
+
"randomSeed": RANDOM_STATE,
|
| 278 |
+
},
|
| 279 |
+
"metadata": {
|
| 280 |
+
"method": "umap_from_fontclip_python",
|
| 281 |
+
"model": "FontCLIP ViT-B/32 (fine-tuned for typography)",
|
| 282 |
+
"pca_components": PCA_COMPONENTS,
|
| 283 |
+
"knn_neighbors": KNN_K,
|
| 284 |
+
"umap_init": "spectral",
|
| 285 |
+
"total_fonts": len(fonts),
|
| 286 |
+
"note": "FontCLIP embeddings with spectral UMAP and high-dim k-NN",
|
| 287 |
+
},
|
| 288 |
+
"fonts": fonts,
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 292 |
+
os.makedirs(RESULTS_DIR, exist_ok=True)
|
| 293 |
+
|
| 294 |
+
out1 = OUTPUT_DIR / "typography_data_fontclip.json"
|
| 295 |
+
with open(out1, "w") as f:
|
| 296 |
+
json.dump(result, f, indent=2)
|
| 297 |
+
print(f"💾 Saved: {out1}")
|
| 298 |
+
|
| 299 |
+
from datetime import datetime
|
| 300 |
+
ts = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
| 301 |
+
out2 = RESULTS_DIR / f"fontclip-spectral_{ts}.json"
|
| 302 |
+
with open(out2, "w") as f:
|
| 303 |
+
json.dump(result, f, indent=2)
|
| 304 |
+
print(f"💾 Saved: {out2}")
|
| 305 |
+
|
| 306 |
+
return out1
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def main():
|
| 310 |
+
device = "mps" if torch.backends.mps.is_available() else "cpu"
|
| 311 |
+
print(f"🖥️ Device: {device}\n")
|
| 312 |
+
|
| 313 |
+
if not CHECKPOINT_PATH.exists():
|
| 314 |
+
print(f"❌ Checkpoint manquant: {CHECKPOINT_PATH}")
|
| 315 |
+
print(f" Téléchargez-le d'abord via:")
|
| 316 |
+
print(f" curl -L 'https://drive.usercontent.google.com/download?id=1Tym7rAIuaGr6Gv-gZRSJmPstQjOWPgl1&export=download&confirm=t' -o '{CHECKPOINT_PATH}'")
|
| 317 |
+
sys.exit(1)
|
| 318 |
+
print(f"✅ Checkpoint found: {CHECKPOINT_PATH} ({CHECKPOINT_PATH.stat().st_size / 1e6:.0f} MB)")
|
| 319 |
+
|
| 320 |
+
model, preprocess = load_fontclip_model(device)
|
| 321 |
+
font_ids, embeddings = generate_embeddings(model, preprocess, device)
|
| 322 |
+
|
| 323 |
+
np.savez_compressed(OUTPUT_DIR / "embeddings_fontclip.npz", font_ids=font_ids, embeddings=embeddings)
|
| 324 |
+
|
| 325 |
+
image_names = None
|
| 326 |
+
umap_ids = font_ids
|
| 327 |
+
umap_embeddings = embeddings
|
| 328 |
+
|
| 329 |
+
if ENABLE_FONT_FUSION:
|
| 330 |
+
font_index = {}
|
| 331 |
+
if FONT_INDEX_PATH.exists():
|
| 332 |
+
with open(FONT_INDEX_PATH) as f:
|
| 333 |
+
font_index = json.load(f)
|
| 334 |
+
umap_ids, umap_embeddings, image_names = merge_font_families(font_ids, embeddings, font_index)
|
| 335 |
+
|
| 336 |
+
coords, var = run_pca_umap(umap_embeddings)
|
| 337 |
+
knn_indices = compute_knn(umap_embeddings)
|
| 338 |
+
out = build_and_save(umap_ids, coords, knn_indices, image_names)
|
| 339 |
+
|
| 340 |
+
print(f"\n🎉 Done! {len(umap_ids)} fonts processed with FontCLIP")
|
| 341 |
+
print(f" PCA variance: {var:.1f}%, UMAP spectral init")
|
| 342 |
+
if ENABLE_FONT_FUSION:
|
| 343 |
+
print(f" Font fusion enabled ({len(font_ids)} → {len(umap_ids)})")
|
| 344 |
+
print(f"\n💡 To deploy: node 8-deploy-to-prod.mjs fontclip-spectral")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
main()
|