tfrere HF Staff Cursor commited on
Commit
2fc4361
·
1 Parent(s): b700c24

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>

Files changed (30) hide show
  1. public/data/typography_data.json +0 -0
  2. public/debug-umap/fontclip-spectral_2026-02-18T15-31-31.json +0 -0
  3. public/debug-umap/index.json +24 -2
  4. src/components/DebugUMAP/DebugUMAP.js +1 -24
  5. src/components/DebugUMAP/components/LiveUMAPPanel.js +23 -20
  6. src/components/DebugUMAP/hooks/index.js +0 -1
  7. src/components/DebugUMAP/hooks/useKeyboardNavigation.js +1 -3
  8. src/components/DebugUMAP/hooks/useLeva.js +53 -88
  9. src/components/DebugUMAP/store/useDebugUMAPStore.js +8 -56
  10. src/components/DebugUMAP/utils/umapCalculator.js +104 -6
  11. src/components/DebugUMAP/workers/umap.worker.js +9 -7
  12. src/components/FontMap/FontMap.js +88 -119
  13. src/components/FontMap/components/AboutModal.js +36 -27
  14. src/components/FontMap/components/ActiveFont.js +81 -103
  15. src/components/FontMap/components/TooltipManager.js +2 -4
  16. src/components/FontMap/components/controls/CategoryLegend.js +22 -0
  17. src/components/FontMap/hooks/useMapRenderer.js +383 -0
  18. src/components/FontMap/hooks/useMapZoom.js +128 -0
  19. src/components/FontMap/hooks/useTooltipOptimized.js +12 -16
  20. src/components/FontMap/styles/about-modal.css +75 -0
  21. src/components/FontMap/styles/intro-modal.css +0 -1
  22. src/components/FontMap/styles/layout.css +92 -62
  23. src/hooks/useStaticFontData.js +14 -12
  24. src/store/fontMapStore.js +10 -0
  25. src/typography/new-pipe/2-generate-svgs.mjs +107 -5
  26. src/typography/new-pipe/3-generate-pngs.mjs +25 -21
  27. src/typography/new-pipe/4-generate-embeddings.mjs +9 -4
  28. src/typography/new-pipe/5-batch-umap.mjs +121 -16
  29. src/typography/new-pipe/8-deploy-to-prod.mjs +6 -2
  30. 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": "2025-10-26T19:58:12.762Z",
3
- "total_configs": 7,
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, useLocalStorage } from './hooks';
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
- // État local pour le debounce
20
- const [config, setConfig] = useState({
21
- nNeighbors: 15,
22
- minDist: 1.0,
23
- enableFontFusion: true
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
- // Configuration Leva avec API fonctionnelle pour avoir accès à 'set'
49
  const [, set] = useControls(() => ({
50
  'Live UMAP 🧪': folder({
51
  nNeighbors: {
52
- value: 15,
53
  min: 5,
54
  max: 50,
55
  step: 1,
56
  label: 'Neighbors',
57
- onChange: (v) => setConfig(prev => ({ ...prev, nNeighbors: v }))
58
  },
59
  minDist: {
60
- value: 1.0,
61
  min: 0.1,
62
  max: 2.0,
63
  step: 0.1,
64
  label: 'Min Dist',
65
- onChange: (v) => setConfig(prev => ({ ...prev, minDist: v }))
66
  },
67
  enableFontFusion: {
68
- value: true,
69
  label: 'Fusion Families',
70
- onChange: (v) => setConfig(prev => ({ ...prev, enableFontFusion: 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
- // Effet pour l'auto-calcul (Debounced)
91
  useEffect(() => {
92
- // Annuler le timeout précédent
 
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); // 600ms de délai
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, saveToLocalStorage]);
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, useStoreContext } from 'leva';
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
- // Store Leva pour mettre à jour les contrôles
25
- const store = useStoreContext();
26
 
27
- // Configuration des contrôles Leva - Initialiser avec les valeurs du store
28
- const controls = useControls({
29
- // Slider de configuration
30
  Configuration: {
31
- value: currentConfigIndex, // Utiliser la valeur du store
32
  min: 0,
33
- max: Math.max(8, configs.length - 1), // Au moins 8 pour permettre le test
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
- // Bouton pour reset defaults
58
- 'Reset Defaults': { button: true }
59
- });
60
 
61
- // Mettre à jour le store Zustand quand Leva change
62
- React.useEffect(() => {
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 (controls['Category Colors'] !== undefined) {
72
- setUseCategoryColors(controls['Category Colors']);
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 (controls['Dark Mode'] !== undefined) {
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
- }, [controls['Reset Defaults'], resetToDefaults]);
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', // Clé pour localStorage
135
- // Ne persister que les états visuels et la config actuelle
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; // Éviter division par zéro
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: 50 } });
64
  const normalizedData = normalizeData(mergedEmbeddings);
65
 
66
- // 5. UMAP
 
 
 
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
- // On doit quand même initialiser, mais ça sera instantané car KNN est fourni
103
- umap.initializeFit(normalizedData);
104
  } else {
105
  console.log('🆕 Calculating new KNN');
106
- umap.initializeFit(normalizedData);
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 { useD3Visualization } from './hooks/useD3Visualization';
 
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 unifié
 
27
  */
28
  const FontMap = ({ darkMode = false }) => {
29
- // États locaux (seulement ceux vraiment nécessaires)
 
 
 
30
  const [filter, setFilter] = useState('all');
31
  const [searchTerm, setSearchTerm] = useState('');
32
- const [appState, setAppState] = useState('loading'); // 'loading', 'intro', 'ready'
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
- const handleFilterChange = (newFilter) => {
44
- setFilter(newFilter);
45
- };
46
-
47
- const handleSearchChange = (newSearchTerm) => {
48
- setSearchTerm(newSearchTerm);
49
- };
50
 
51
- const handleFontSelect = (font) => {
52
- console.log('FontMap: handleFontSelect called with', font);
53
- setSelectedFont(font);
54
- };
 
 
 
 
 
 
 
55
 
56
- const handleCloseDetails = () => {
57
- setSelectedFont(null);
58
- };
59
 
60
- const handleShowAbout = () => {
61
- setShowAboutModal(true);
62
- };
63
 
64
- const handleCloseAbout = () => {
65
- setShowAboutModal(false);
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
- const handleStartExploring = () => {
77
- setAppState('ready');
78
- };
79
-
80
- // Hooks personnalisés - Utilisation des données statiques pour la production
81
- const { fonts, glyphPaths, loading, error } = useStaticFontData();
82
-
83
- // Hook pour la navigation aux flèches (doit être avant useD3Visualization)
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
- // Configurer les callbacks globaux pour le TooltipManager
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
- // Surveiller les changements de selectedFont
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={handleSearchChange}
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={handleFilterChange}
174
  />
175
  </div>
176
  </div>
@@ -179,19 +177,13 @@ const FontMap = ({ darkMode = false }) => {
179
  selectedFont={selectedFont}
180
  fonts={fonts}
181
  darkMode={darkMode}
182
- onClose={handleCloseDetails}
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
- {/* Overlay unifié pour loading */}
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
- {/* Moniteur FPS (dev seulement) */}
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>machine learning</strong> to create an interactive visualization of font relationships.
30
- The process transforms fonts into mathematical representations and uses <strong>dimensionality reduction</strong> to reveal hidden similarities.
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>Font Rendering</strong>
45
  </div>
46
- <p className="step-description">Each font is rendered as a <strong>40×40 pixel</strong> bitmap of the letter "A" for consistent comparison</p>
47
  </div>
48
  <div className="step-example">
49
- <div className="mini-matrix">
50
- <div className="mini-row">· · ■ ■ · ·</div>
51
- <div className="mini-row">· ·</div>
52
- <div className="mini-row">■ · · ■ ■</div>
53
- <div className="mini-row">■ ■ ■</div>
54
- <div className="mini-row">■ ■ · · ■ ■</div>
55
- <div className="mini-row"> · · ■ ■</div>
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>Feature Extraction</strong>
65
  </div>
66
- <p className="step-description">Bitmaps are converted into <strong>1600-dimensional feature vectors</strong> representing pixel intensities</p>
 
 
67
  </div>
68
  <div className="step-example">
69
  <div className="mini-matrix">
70
- <div className="mini-row"><span className="zero-value">0.0</span>,<span className="zero-value">0.0</span>,<strong>0.1,0.1</strong>,<span className="zero-value">0.0</span>,<span className="zero-value">0.0</span></div>
71
- <div className="mini-row"><span className="zero-value">0.0</span>,<strong>0.1,0.1,0.1,0.1</strong>,<span className="zero-value">0.0</span></div>
72
- <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>
73
- <div className="mini-row"><strong>0.1,0.1,0.1,0.1,0.1,0.1</strong></div>
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>Dimensionality Reduction</strong>
85
  </div>
86
  <p className="step-description">
87
- <strong>UMAP algorithm</strong> projects high-dimensional vectors into <strong>2D space</strong> while preserving similarity relationships
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="coordinates">
 
 
 
 
 
 
 
 
 
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> - you can explore the code, modify the parameters, or run it on your own font collection.
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
- This is a recreation of the original <strong>IDEO Font Map</strong> using modern machine learning techniques.
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 &times; 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 &times; 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">&nbsp;&nbsp;0.29, <strong>-0.74</strong>, 0.18, 0.51, -0.33,</div>
74
+ <div className="mini-row">&nbsp;&nbsp;0.02, <strong>0.96</strong>, -0.47, 0.11, 0.68,</div>
75
+ <div className="mini-row">&nbsp;&nbsp;-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 &rarr; 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">&rarr;</div>
95
+ <div className="pipeline-node">50D</div>
96
+ <div className="pipeline-arrow">&rarr;</div>
97
+ <div className="pipeline-node">fusion</div>
98
+ <div className="pipeline-arrow">&rarr;</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> &mdash; 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> &mdash; 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 &rarr;
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 &mdash; a CLIP model fine-tuned specifically for typography &mdash; 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 when selectedFont changes
29
  useEffect(() => {
30
- // Ne pas réinitialiser les états si on a déjà du contenu chargé
31
- // Cela évite le flicker lors du changement de police
32
- const shouldResetStates = !fontPreviewLoaded || !sentencePreviewLoaded;
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
- // Simulate SVG loading since use elements don't trigger onLoad
51
- const timer = setTimeout(() => {
52
- setFontPreviewLoaded(true);
53
- }, 100);
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, fontPreviewLoaded, sentencePreviewLoaded]);
67
 
68
- // Effet pour faire tourner les polices dans le placeholder
69
  useEffect(() => {
70
- if (selectedFont) return; // Pas de rotation si une police est sélectionnée
71
-
72
  const interval = setInterval(() => {
73
- setPlaceholderFontIndex(prev => (prev + 1) % defaultFonts.length);
74
- }, 200); // Change toutes les 0.8 secondes
75
-
76
  return () => clearInterval(interval);
77
- }, [selectedFont, defaultFonts.length]);
78
 
79
- // Helper function to handle image loading
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
- // Helper function to set a timeout for image loading
91
- const setImageLoadTimeout = (fontName) => {
92
- // Clear any existing timeout
93
- if (imageLoadTimeouts.current[fontName]) {
94
- clearTimeout(imageLoadTimeouts.current[fontName]);
 
 
 
 
 
 
 
 
 
 
 
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
- // If no font is selected, show a placeholder
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: defaultFonts[placeholderFontIndex],
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 }, index) => {
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 selectedGlyph = viewportGroup.selectAll('.font-glyph-group')
173
- .filter(function(d) {
174
- return d && d.name && window.currentSelectedFont && d.name === window.currentSelectedFont.name;
175
- });
176
-
177
- if (!selectedGlyph.empty()) {
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 hoveredGlyph = viewportGroup.selectAll('.font-glyph-group')
186
- .filter(function(d) {
187
- return d && d.name && window.currentHoveredFont && d.name === window.currentHoveredFont.name;
188
- });
189
-
190
- if (!hoveredGlyph.empty()) {
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é - anti-pixellisation */
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
- /* Zoom fluide et réactif - anti-pixellisation */
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
- /* Optimisation pour le zoom/pan */
336
- .fontmap-svg.zooming {
337
- cursor: grab;
338
- }
339
-
340
- .fontmap-svg.zooming:active {
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
- const response = await fetch('/data/font-map.json');
 
 
53
 
54
- if (!response.ok) {
55
- if (response.status === 404) {
56
- throw new Error('Font map data not found (404). Please export it from Debug view first.');
 
 
 
 
 
57
  }
58
- const contentType = response.headers.get('content-type');
59
- if (contentType && contentType.includes('text/html')) {
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(2, 0, { fontName: fontData.family });
314
 
315
  const result = await processFontFamily(fontId, fontData, i, fontIds.length);
316
 
317
- progressBar.update(2, { fontName: fontData.family });
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 * 2} fichiers SVG générés`));
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 = 40; // Final size 40x40 pixels
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 de lettre A
 
172
  */
173
- async function processLetterASVG(svgFile, currentIndex, totalFiles) {
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
- // Extraire le nom de la police du nom de fichier
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 toutes les lettres A...\n`));
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
- if (letterASvgFiles.length === 0) {
235
- throw new Error('Aucun fichier SVG de lettre A trouvé dans ' + SVGS_DIR);
 
 
 
 
 
236
  }
237
 
238
- console.log(chalk.cyan(`📁 ${letterASvgFiles.length} fichiers SVG de lettre A trouvés\n`));
 
239
 
240
  const results = [];
241
  let successCount = 0;
242
  let errorCount = 0;
243
 
244
- // Traiter chaque fichier SVG
245
- for (let i = 0; i < letterASvgFiles.length; i++) {
246
- const svgFile = letterASvgFiles[i];
247
 
248
- console.log(chalk.magenta(`\n🔤 [${i + 1}/${letterASvgFiles.length}] Traitement de "${svgFile}"`));
249
 
250
- // Démarrer la barre de progression
251
  progressBar.start(1, 0, { fontName: svgFile });
252
 
253
- const result = await processLetterASVG(svgFile, i, letterASvgFiles.length);
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
- // Afficher le progrès global
269
- const progress = ((i + 1) / letterASvgFiles.length) * 100;
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(` - ${letterASvgFiles.length} fichiers traités`));
 
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 tous les fichiers PNG
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 pngFiles = files.filter(file => file.endsWith('_a.png'));
 
 
 
 
 
 
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.to2DArray();
211
  }
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
  /**
215
- * Génère l'embedding UMAP avec une config donnée
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  */
217
  function generateUMAPWithConfig(config, embeddingMatrices) {
218
- const normalizedData = normalizeData(embeddingMatrices);
 
 
 
 
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', // Toujours cosine
235
  random: randomFn
236
  };
237
 
 
238
  const umap = new UMAP(umapParams);
239
- const embedding = umap.fit(normalizedData);
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: "umap_from_clip_embeddings_pure_visual",
264
- note: "100% visual embeddings, no category influence"
 
 
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
- // Générer UMAP (directement sur embeddings visuels, sans catégories)
 
 
 
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 resultFile = files.find(f => f.startsWith(configName + '_'));
 
 
 
 
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()