tfrere HF Staff Claude Opus 4.7 (1M context) commited on
Commit
4319071
·
1 Parent(s): 7a7443a

feat: mobile UX overhaul + focus mode polish + visual hover hitbox

Browse files

Mobile (<=768px):
- Sidebar becomes a sticky top bar with just search and filters
- ActiveFont becomes a slide-in drawer with chevron-back
- Tap shows the tooltip with an "Open" button instead of opening directly
- Hide focus hint and bottom controls

Focus mode:
- Esc to exit, no longer resets zoom on close
- Selected font zoom level bumped from 2.5x to 4x
- Bottom-left hint shows arrow keys + "use arrow keys to navigate"

Visual:
- Invisible circle hitbox under each glyph for easier targeting
- Hover state shows a subtle ring + tint, persists when font is active
- Outlined sun/moon dark-mode toggle with rotation on toggle
- Centroid labels follow dark mode (black halo in dark)

Tooltip:
- Dark mode driven by CSS class (no stale inline styles after toggle)
- Sentence preview inverts in dark via CSS
- Unselectable, z-indexed below sidebar

Map data:
- typography_data.json re-dilated (radius=13, ticks=140, origin=0.03)
- Sprite lookup falls back to imageName-derived key (fixes "just" font missing)

Tooling:
- npm run overlap script + updated defaults (10/140/0.03)
- useMediaQuery hook for mobile detection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

package.json CHANGED
@@ -36,7 +36,9 @@
36
  "start": "react-scripts start",
37
  "build": "react-scripts build",
38
  "test": "react-scripts test",
39
- "eject": "react-scripts eject"
 
 
40
  },
41
  "eslintConfig": {
42
  "extends": [
 
36
  "start": "react-scripts start",
37
  "build": "react-scripts build",
38
  "test": "react-scripts test",
39
+ "eject": "react-scripts eject",
40
+ "overlap": "node scripts/apply-overlap-removal.mjs",
41
+ "overlap:dry": "node scripts/apply-overlap-removal.mjs --dry-run"
42
  },
43
  "eslintConfig": {
44
  "extends": [
public/data/typography_data.json CHANGED
The diff for this file is too large to render. See raw diff
 
scripts/apply-overlap-removal.mjs ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * apply-overlap-removal.mjs
3
+ *
4
+ * Post-processes a font-map JSON file (font-map.json or typography_data.json)
5
+ * to remove glyph overlaps using a D3 force simulation, then writes the result
6
+ * back to the same file (or to --output).
7
+ *
8
+ * Usage:
9
+ * node scripts/apply-overlap-removal.mjs [options]
10
+ *
11
+ * Options:
12
+ * --input <path> Source JSON (default: public/data/font-map.json)
13
+ * --output <path> Output JSON (default: same as --input, overwrites in place)
14
+ * --radius <px> Collision radius in canonical pixels (default: 10)
15
+ * --ticks <n> Simulation steps (default: 140)
16
+ * --origin <0-1> Pull-back strength toward UMAP origin (default: 0.03)
17
+ * --width <px> Canonical canvas width (default: 1920)
18
+ * --height <px> Canonical canvas height (default: 1080)
19
+ * --padding <px> Padding inside canonical canvas (default: 40)
20
+ * --dry-run Print stats but don't write the file
21
+ */
22
+
23
+ import { readFileSync, writeFileSync } from 'fs';
24
+ import { resolve } from 'path';
25
+ import { fileURLToPath } from 'url';
26
+ import { createRequire } from 'module';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Parse CLI args
30
+ // ---------------------------------------------------------------------------
31
+ const args = process.argv.slice(2);
32
+ const get = (flag, fallback) => {
33
+ const i = args.indexOf(flag);
34
+ return i !== -1 ? args[i + 1] : fallback;
35
+ };
36
+ const has = (flag) => args.includes(flag);
37
+
38
+ const ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
39
+ const inputPath = resolve(ROOT, get('--input', 'public/data/font-map.json'));
40
+ const outputPath = resolve(ROOT, get('--output', inputPath));
41
+ const RADIUS = parseFloat(get('--radius', '10'));
42
+ const TICKS = parseInt(get('--ticks', '140'), 10);
43
+ const ORIGIN_STRENGTH = parseFloat(get('--origin', '0.03'));
44
+ const W = parseFloat(get('--width', '1920'));
45
+ const H = parseFloat(get('--height', '1080'));
46
+ const PADDING = parseFloat(get('--padding', '40'));
47
+ const DRY_RUN = has('--dry-run');
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Load D3 force (ESM)
51
+ // ---------------------------------------------------------------------------
52
+ const require = createRequire(import.meta.url);
53
+ // d3-force is a CommonJS module in this version
54
+ const { forceSimulation, forceCollide, forceX, forceY } = await import('d3-force');
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Load data
58
+ // ---------------------------------------------------------------------------
59
+ console.log(`📂 Input: ${inputPath}`);
60
+ let raw;
61
+ try {
62
+ raw = JSON.parse(readFileSync(inputPath, 'utf8'));
63
+ } catch (e) {
64
+ // Fallback to typography_data.json
65
+ const fallback = resolve(ROOT, 'public/data/typography_data.json');
66
+ console.warn(`⚠️ font-map.json not found, trying ${fallback}`);
67
+ raw = JSON.parse(readFileSync(fallback, 'utf8'));
68
+ }
69
+
70
+ const fonts = raw.fonts;
71
+ console.log(`✅ Loaded ${fonts.length} fonts`);
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Map UMAP coords → canonical screen space
75
+ // ---------------------------------------------------------------------------
76
+ const xs = fonts.map(f => f.x);
77
+ const ys = fonts.map(f => f.y);
78
+ const xMin = Math.min(...xs), xMax = Math.max(...xs);
79
+ const yMin = Math.min(...ys), yMax = Math.max(...ys);
80
+
81
+ const toScreenX = x => ((x - xMin) / (xMax - xMin)) * (W - 2 * PADDING) + PADDING;
82
+ const toScreenY = y => ((yMax - y) / (yMax - yMin)) * (H - 2 * PADDING) + PADDING;
83
+
84
+ const nodes = fonts.map(f => ({
85
+ id: f.id,
86
+ x: toScreenX(f.x),
87
+ y: toScreenY(f.y),
88
+ ox: toScreenX(f.x), // original position for pull-back
89
+ oy: toScreenY(f.y),
90
+ }));
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Force simulation
94
+ // ---------------------------------------------------------------------------
95
+ console.log(`⚙️ Running simulation — radius: ${RADIUS}px ticks: ${TICKS} origin-strength: ${ORIGIN_STRENGTH}`);
96
+
97
+ const sim = forceSimulation(nodes)
98
+ .force('collide', forceCollide(RADIUS).strength(1).iterations(3))
99
+ .force('x', forceX(n => n.ox).strength(ORIGIN_STRENGTH))
100
+ .force('y', forceY(n => n.oy).strength(ORIGIN_STRENGTH))
101
+ .stop();
102
+
103
+ for (let i = 0; i < TICKS; i++) sim.tick();
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Measure displacement
107
+ // ---------------------------------------------------------------------------
108
+ const displacements = nodes.map(n => Math.hypot(n.x - n.ox, n.y - n.oy));
109
+ const maxD = Math.max(...displacements).toFixed(1);
110
+ const avgD = (displacements.reduce((a, b) => a + b, 0) / displacements.length).toFixed(1);
111
+ console.log(`📏 Displacement — avg: ${avgD}px max: ${maxD}px (canonical ${W}×${H})`);
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Write positions back into font objects
115
+ // The renderer normalises by min/max, so storing canonical pixel coords is fine —
116
+ // relative positions are preserved after re-scaling to actual viewport.
117
+ // ---------------------------------------------------------------------------
118
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
119
+ raw.fonts = fonts.map(f => {
120
+ const n = nodeMap.get(f.id);
121
+ return n ? { ...f, x: n.x, y: n.y } : f;
122
+ });
123
+
124
+ if (DRY_RUN) {
125
+ console.log('🔍 Dry run — file not written.');
126
+ } else {
127
+ writeFileSync(outputPath, JSON.stringify(raw, null, 2));
128
+ console.log(`💾 Written to ${outputPath}`);
129
+ }
src/components/DebugUMAP/components/LiveUMAPPanel.js CHANGED
@@ -5,11 +5,13 @@
5
  import React, { useEffect, useRef, useState } from 'react';
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
 
@@ -28,6 +30,9 @@ const LiveUMAPPanel = ({ onResult }) => {
28
  });
29
  };
30
 
 
 
 
31
  const handleExport = () => {
32
  const result = lastResultRef.current;
33
  if (!result) {
@@ -35,7 +40,11 @@ const LiveUMAPPanel = ({ onResult }) => {
35
  return;
36
  }
37
 
38
- const jsonString = JSON.stringify(result, null, 2);
 
 
 
 
39
  const blob = new Blob([jsonString], { type: 'application/json' });
40
  const url = URL.createObjectURL(blob);
41
 
 
5
  import React, { useEffect, useRef, useState } from 'react';
6
  import { useControls, button, folder } from 'leva';
7
  import { useLiveUMAP } from '../hooks';
8
+ import { useDebugUMAPStore } from '../store';
9
 
10
  const DEFAULTS = { nNeighbors: 12, minDist: 1.0, enableFontFusion: true };
11
 
12
  const LiveUMAPPanel = ({ onResult }) => {
13
  const { calculate, isCalculating, progress, error, result: lastResult } = useLiveUMAP();
14
+ const dilatedFonts = useDebugUMAPStore((state) => state.dilatedFonts);
15
  const timeoutRef = useRef(null);
16
  const userChangedRef = useRef(false);
17
 
 
30
  });
31
  };
32
 
33
+ const dilatedFontsRef = useRef(dilatedFonts);
34
+ useEffect(() => { dilatedFontsRef.current = dilatedFonts; }, [dilatedFonts]);
35
+
36
  const handleExport = () => {
37
  const result = lastResultRef.current;
38
  if (!result) {
 
40
  return;
41
  }
42
 
43
+ // If overlap removal was applied, export the dilated positions
44
+ const fonts = dilatedFontsRef.current?.length > 0 ? dilatedFontsRef.current : result.fonts;
45
+ const exportData = { ...result, fonts };
46
+
47
+ const jsonString = JSON.stringify(exportData, null, 2);
48
  const blob = new Blob([jsonString], { type: 'application/json' });
49
  const url = URL.createObjectURL(blob);
50
 
src/components/DebugUMAP/hooks/useGlyphRenderer.js CHANGED
@@ -4,6 +4,7 @@ import { calculateMappingDimensions, createGlyphTransform } from '../utils/mappi
4
  import { applyColorsToGlyphGroup } from '../utils/colorUtils.js';
5
  import { getConfig } from '../config/mapConfig.js';
6
  import { useDebugUMAPStore } from '../store';
 
7
 
8
  /**
9
  * Hook pour gérer le rendu des glyphes avec Zustand
@@ -15,9 +16,13 @@ export function useGlyphRenderer({ svgRef, enabled = true }) {
15
  const useCategoryColors = useDebugUMAPStore((state) => state.useCategoryColors);
16
  const darkMode = useDebugUMAPStore((state) => state.darkMode);
17
  const showCentroids = useDebugUMAPStore((state) => state.showCentroids);
18
- const setCurrentFonts = useDebugUMAPStore((state) => state.setCurrentFonts);
 
 
 
 
19
  const setMappingFunctions = useDebugUMAPStore((state) => state.setMappingFunctions);
20
- const setGlyphsLoaded = useDebugUMAPStore((state) => state.setGlyphsLoaded);
21
 
22
  // Références pour le cleanup
23
  const abortControllerRef = useRef(null);
@@ -80,9 +85,34 @@ export function useGlyphRenderer({ svgRef, enabled = true }) {
80
 
81
  // Calculer les dimensions de mapping
82
  const { mapX, mapY } = calculateMappingDimensions(data.fonts);
83
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  // Stocker les données pour les centroïdes
85
  setCurrentFonts(data.fonts);
 
86
  setMappingFunctions({ mapX, mapY });
87
  setGlyphsLoaded(false);
88
 
@@ -105,7 +135,10 @@ export function useGlyphRenderer({ svgRef, enabled = true }) {
105
  if (!svgRef.current || abortControllerRef.current.signal.aborted) {
106
  return;
107
  }
108
- renderGlyph(viewportGroup, svgContent, font, mapX, mapY, baseGlyphSize, useCategoryColors, darkMode);
 
 
 
109
  })
110
  .catch((err) => {
111
  if (err.name !== 'AbortError') {
@@ -159,7 +192,7 @@ export function useGlyphRenderer({ svgRef, enabled = true }) {
159
  timeoutRefs.current.forEach(timeout => clearTimeout(timeout));
160
  timeoutRefs.current = [];
161
  };
162
- }, [enabled, configs, currentConfigIndex]);
163
 
164
  // Mettre à jour les couleurs des glyphes existants
165
  useEffect(() => {
@@ -244,14 +277,10 @@ export function useGlyphRenderer({ svgRef, enabled = true }) {
244
  /**
245
  * Rend un glyphe individuel
246
  */
247
- function renderGlyph(viewportGroup, svgContent, font, mapX, mapY, baseGlyphSize, useCategoryColors, darkMode) {
248
  // Créer un groupe pour chaque glyphe
249
  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
250
- const originalTransform = createGlyphTransform(
251
- mapX(font.x),
252
- mapY(font.y),
253
- baseGlyphSize
254
- );
255
 
256
  group.setAttribute('transform', originalTransform);
257
  group.setAttribute('data-original-transform', originalTransform);
 
4
  import { applyColorsToGlyphGroup } from '../utils/colorUtils.js';
5
  import { getConfig } from '../config/mapConfig.js';
6
  import { useDebugUMAPStore } from '../store';
7
+ import { applyOverlapRemoval } from '../../FontMap/utils/voronoiDilation.js';
8
 
9
  /**
10
  * Hook pour gérer le rendu des glyphes avec Zustand
 
16
  const useCategoryColors = useDebugUMAPStore((state) => state.useCategoryColors);
17
  const darkMode = useDebugUMAPStore((state) => state.darkMode);
18
  const showCentroids = useDebugUMAPStore((state) => state.showCentroids);
19
+ const overlapRadius = useDebugUMAPStore((state) => state.overlapRadius);
20
+ const overlapTicks = useDebugUMAPStore((state) => state.overlapTicks);
21
+ const overlapOriginStrength = useDebugUMAPStore((state) => state.overlapOriginStrength);
22
+ const setCurrentFonts = useDebugUMAPStore((state) => state.setCurrentFonts);
23
+ const setDilatedFonts = useDebugUMAPStore((state) => state.setDilatedFonts);
24
  const setMappingFunctions = useDebugUMAPStore((state) => state.setMappingFunctions);
25
+ const setGlyphsLoaded = useDebugUMAPStore((state) => state.setGlyphsLoaded);
26
 
27
  // Références pour le cleanup
28
  const abortControllerRef = useRef(null);
 
85
 
86
  // Calculer les dimensions de mapping
87
  const { mapX, mapY } = calculateMappingDimensions(data.fonts);
88
+
89
+ // Positions écran initiales
90
+ const rawPositions = data.fonts.map(f => ({
91
+ id: f.id,
92
+ x: mapX(f.x),
93
+ y: mapY(f.y),
94
+ }));
95
+
96
+ // Appliquer l'overlap removal si activé
97
+ const resolvedPositions = overlapRadius > 0
98
+ ? applyOverlapRemoval(rawPositions, {
99
+ collideRadius: overlapRadius,
100
+ ticks: overlapTicks,
101
+ originStrength: overlapOriginStrength,
102
+ })
103
+ : rawPositions;
104
+
105
+ const positionMap = new Map(resolvedPositions.map(p => [p.id, p]));
106
+
107
+ // Stocker les fonts avec positions dilatées (pour l'export)
108
+ const dilated = data.fonts.map(f => {
109
+ const p = positionMap.get(f.id);
110
+ return p ? { ...f, x: p.x, y: p.y } : f;
111
+ });
112
+
113
  // Stocker les données pour les centroïdes
114
  setCurrentFonts(data.fonts);
115
+ setDilatedFonts(dilated);
116
  setMappingFunctions({ mapX, mapY });
117
  setGlyphsLoaded(false);
118
 
 
135
  if (!svgRef.current || abortControllerRef.current.signal.aborted) {
136
  return;
137
  }
138
+ const pos = positionMap.get(font.id);
139
+ const screenX = pos ? pos.x : mapX(font.x);
140
+ const screenY = pos ? pos.y : mapY(font.y);
141
+ renderGlyph(viewportGroup, svgContent, font, screenX, screenY, baseGlyphSize, useCategoryColors, darkMode);
142
  })
143
  .catch((err) => {
144
  if (err.name !== 'AbortError') {
 
192
  timeoutRefs.current.forEach(timeout => clearTimeout(timeout));
193
  timeoutRefs.current = [];
194
  };
195
+ }, [enabled, configs, currentConfigIndex, overlapRadius, overlapTicks, overlapOriginStrength]);
196
 
197
  // Mettre à jour les couleurs des glyphes existants
198
  useEffect(() => {
 
277
  /**
278
  * Rend un glyphe individuel
279
  */
280
+ function renderGlyph(viewportGroup, svgContent, font, screenX, screenY, baseGlyphSize, useCategoryColors, darkMode) {
281
  // Créer un groupe pour chaque glyphe
282
  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
283
+ const originalTransform = createGlyphTransform(screenX, screenY, baseGlyphSize);
 
 
 
 
284
 
285
  group.setAttribute('transform', originalTransform);
286
  group.setAttribute('data-original-transform', originalTransform);
src/components/DebugUMAP/hooks/useLeva.js CHANGED
@@ -1,5 +1,5 @@
1
  import React from 'react';
2
- import { useControls } from 'leva';
3
  import { useDebugUMAPStore } from '../store';
4
  import { getConfig } from '../config/mapConfig.js';
5
 
@@ -48,6 +48,29 @@ export function useLeva({ onResetZoom }) {
48
  value: getStore().showCentroids,
49
  onChange: (v) => { getStore().setShowCentroids(v); },
50
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  'Reset Zoom': { button: true },
52
  'Reset Defaults': { button: true },
53
  }), [maxConfig]);
 
1
  import React from 'react';
2
+ import { useControls, folder } from 'leva';
3
  import { useDebugUMAPStore } from '../store';
4
  import { getConfig } from '../config/mapConfig.js';
5
 
 
48
  value: getStore().showCentroids,
49
  onChange: (v) => { getStore().setShowCentroids(v); },
50
  },
51
+ 'Overlap Removal 🔧': folder({
52
+ 'Radius (px)': {
53
+ value: getStore().overlapRadius,
54
+ min: 0,
55
+ max: 80,
56
+ step: 1,
57
+ onChange: (v) => { getStore().setOverlapRadius(v); },
58
+ },
59
+ Ticks: {
60
+ value: getStore().overlapTicks,
61
+ min: 0,
62
+ max: 300,
63
+ step: 10,
64
+ onChange: (v) => { getStore().setOverlapTicks(v); },
65
+ },
66
+ 'Pull to origin': {
67
+ value: getStore().overlapOriginStrength,
68
+ min: 0,
69
+ max: 0.5,
70
+ step: 0.01,
71
+ onChange: (v) => { getStore().setOverlapOriginStrength(v); },
72
+ },
73
+ }, { collapsed: true }),
74
  'Reset Zoom': { button: true },
75
  'Reset Defaults': { button: true },
76
  }), [maxConfig]);
src/components/DebugUMAP/store/useDebugUMAPStore.js CHANGED
@@ -20,8 +20,14 @@ export const useDebugUMAPStore = create(
20
  darkMode: false,
21
  showCentroids: true,
22
 
 
 
 
 
 
23
  // === État des glyphes ===
24
  currentFonts: [],
 
25
  mappingFunctions: { mapX: null, mapY: null },
26
  glyphsLoaded: false,
27
 
@@ -48,8 +54,14 @@ export const useDebugUMAPStore = create(
48
  setDarkMode: (darkMode) => set({ darkMode }),
49
  setShowCentroids: (showCentroids) => set({ showCentroids }),
50
 
 
 
 
 
 
51
  // === Actions pour les glyphes ===
52
  setCurrentFonts: (fonts) => set({ currentFonts: fonts }),
 
53
  setMappingFunctions: (functions) => set({ mappingFunctions: functions }),
54
  setGlyphsLoaded: (loaded) => set({ glyphsLoaded: loaded }),
55
 
@@ -60,7 +72,11 @@ export const useDebugUMAPStore = create(
60
  baseGlyphSize: 0.25,
61
  darkMode: false,
62
  showCentroids: true,
 
 
 
63
  currentFonts: [],
 
64
  mappingFunctions: { mapX: null, mapY: null },
65
  glyphsLoaded: false,
66
  error: null
 
20
  darkMode: false,
21
  showCentroids: true,
22
 
23
+ // === Overlap removal (forceCollide post-UMAP) ===
24
+ overlapRadius: 10,
25
+ overlapTicks: 140,
26
+ overlapOriginStrength: 0.03,
27
+
28
  // === État des glyphes ===
29
  currentFonts: [],
30
+ dilatedFonts: [], // fonts with overlap-resolved screen positions
31
  mappingFunctions: { mapX: null, mapY: null },
32
  glyphsLoaded: false,
33
 
 
54
  setDarkMode: (darkMode) => set({ darkMode }),
55
  setShowCentroids: (showCentroids) => set({ showCentroids }),
56
 
57
+ // === Actions pour l'overlap removal ===
58
+ setOverlapRadius: (v) => set({ overlapRadius: v }),
59
+ setOverlapTicks: (v) => set({ overlapTicks: v }),
60
+ setOverlapOriginStrength: (v) => set({ overlapOriginStrength: v }),
61
+
62
  // === Actions pour les glyphes ===
63
  setCurrentFonts: (fonts) => set({ currentFonts: fonts }),
64
+ setDilatedFonts: (fonts) => set({ dilatedFonts: fonts }),
65
  setMappingFunctions: (functions) => set({ mappingFunctions: functions }),
66
  setGlyphsLoaded: (loaded) => set({ glyphsLoaded: loaded }),
67
 
 
72
  baseGlyphSize: 0.25,
73
  darkMode: false,
74
  showCentroids: true,
75
+ overlapRadius: 10,
76
+ overlapTicks: 140,
77
+ overlapOriginStrength: 0.03,
78
  currentFonts: [],
79
+ dilatedFonts: [],
80
  mappingFunctions: { mapX: null, mapY: null },
81
  glyphsLoaded: false,
82
  error: null
src/components/FontMap/FontMap.js CHANGED
@@ -3,6 +3,7 @@ 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';
@@ -25,7 +26,7 @@ import './styles/about-modal.css';
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';
@@ -34,6 +35,21 @@ const FontMap = ({ darkMode = false }) => {
34
  const [searchTerm, setSearchTerm] = useState('');
35
  const [appState, setAppState] = useState('loading');
36
  const [showAboutModal, setShowAboutModal] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  const {
39
  selectedFont,
@@ -55,7 +71,8 @@ const FontMap = ({ darkMode = false }) => {
55
  searchTerm,
56
  darkMode,
57
  loading,
58
- enabled: svgReady
 
59
  });
60
 
61
  // ── Zoom simplifié (DebugUMAP-style) ──
@@ -66,6 +83,7 @@ const FontMap = ({ darkMode = false }) => {
66
 
67
  // ── Callbacks ──
68
  function handleFontSelect(font) {
 
69
  setSelectedFont(font);
70
  }
71
 
@@ -77,14 +95,13 @@ const FontMap = ({ darkMode = false }) => {
77
  setHoveredFont(null);
78
  }, [setHoveredFont]);
79
 
80
- // ── Centrage sur la police sélectionnée / reset au désélect ──
 
81
  useEffect(() => {
82
  if (selectedFont) {
83
  centerOnFont(selectedFont);
84
- } else {
85
- resetZoom();
86
  }
87
- }, [selectedFont, centerOnFont, resetZoom]);
88
 
89
  // ── Callbacks globaux pour le TooltipManager ──
90
  useEffect(() => {
@@ -148,7 +165,7 @@ const FontMap = ({ darkMode = false }) => {
148
  }
149
 
150
  return (
151
- <div className={`fontmap-container ${darkMode ? 'dark-mode' : ''}`}>
152
  {/* Symboles SVG cachés pour la sidebar */}
153
  {symbolDefs}
154
 
@@ -206,6 +223,28 @@ const FontMap = ({ darkMode = false }) => {
206
  <h1 className="map-title" data-text="FontMap">FontMap</h1>
207
 
208
  <div className="bottom-controls">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  <CategoryLegend darkMode={darkMode} />
210
  <ZoomControls />
211
  </div>
@@ -214,6 +253,18 @@ const FontMap = ({ darkMode = false }) => {
214
  <svg ref={svgRef} className="fontmap-svg"></svg>
215
  </div>
216
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  {!loading && fonts.length > 0 && (
218
  <TooltipManager
219
  selectedFont={selectedFont}
@@ -221,6 +272,8 @@ const FontMap = ({ darkMode = false }) => {
221
  darkMode={darkMode}
222
  onFontHover={handleFontHover}
223
  onFontUnhover={handleFontUnhover}
 
 
224
  />
225
  )}
226
  </div>
 
3
  import '../FontMap.css';
4
 
5
  import { useStaticFontData } from '../../hooks/useStaticFontData';
6
+ import { useMediaQuery } from '../../hooks/useMediaQuery';
7
  import { useMapRenderer } from './hooks/useMapRenderer';
8
  import { useMapZoom } from './hooks/useMapZoom';
9
  import { useArrowNavigation } from './hooks/useArrowNavigation';
 
26
  * Composant principal FontMap — Moteur de rendu DebugUMAP + UI prod complète.
27
  * Mode debug activable via ?debug=true dans l'URL.
28
  */
29
+ const FontMap = ({ darkMode: darkModeProp = false }) => {
30
  const svgRef = useRef(null);
31
  const [searchParams] = useSearchParams();
32
  const isDebugMode = searchParams.get('debug') === 'true';
 
35
  const [searchTerm, setSearchTerm] = useState('');
36
  const [appState, setAppState] = useState('loading');
37
  const [showAboutModal, setShowAboutModal] = useState(false);
38
+ const [darkMode, setDarkMode] = useState(() => {
39
+ const stored = localStorage.getItem('fontmap-dark-mode');
40
+ return stored !== null ? stored === 'true' : darkModeProp;
41
+ });
42
+ const [iconRotation, setIconRotation] = useState(0);
43
+ const isMobile = useMediaQuery('(max-width: 768px)');
44
+
45
+ useEffect(() => {
46
+ localStorage.setItem('fontmap-dark-mode', String(darkMode));
47
+ }, [darkMode]);
48
+
49
+ const toggleDarkMode = useCallback(() => {
50
+ setIconRotation(r => r + 360);
51
+ setDarkMode(d => !d);
52
+ }, []);
53
 
54
  const {
55
  selectedFont,
 
71
  searchTerm,
72
  darkMode,
73
  loading,
74
+ enabled: svgReady,
75
+ isMobile,
76
  });
77
 
78
  // ── Zoom simplifié (DebugUMAP-style) ──
 
83
 
84
  // ── Callbacks ──
85
  function handleFontSelect(font) {
86
+ setHoveredFont(null);
87
  setSelectedFont(font);
88
  }
89
 
 
95
  setHoveredFont(null);
96
  }, [setHoveredFont]);
97
 
98
+ // ── Centrage sur la police sélectionnée on ne reset PAS le zoom au désélect
99
+ // pour laisser l'utilisateur continuer à explorer là où il était.
100
  useEffect(() => {
101
  if (selectedFont) {
102
  centerOnFont(selectedFont);
 
 
103
  }
104
+ }, [selectedFont, centerOnFont]);
105
 
106
  // ── Callbacks globaux pour le TooltipManager ──
107
  useEffect(() => {
 
165
  }
166
 
167
  return (
168
+ <div className={`fontmap-container ${darkMode ? 'dark-mode' : ''} ${selectedFont ? 'has-focus' : ''}`}>
169
  {/* Symboles SVG cachés pour la sidebar */}
170
  {symbolDefs}
171
 
 
223
  <h1 className="map-title" data-text="FontMap">FontMap</h1>
224
 
225
  <div className="bottom-controls">
226
+ <button
227
+ className="dark-mode-toggle"
228
+ onClick={toggleDarkMode}
229
+ title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
230
+ aria-label="Toggle dark mode"
231
+ >
232
+ <span
233
+ className="dark-mode-toggle-icon"
234
+ style={{ transform: `rotate(${iconRotation}deg)` }}
235
+ >
236
+ {darkMode ? (
237
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
238
+ <circle cx="12" cy="12" r="4" />
239
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
240
+ </svg>
241
+ ) : (
242
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
243
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
244
+ </svg>
245
+ )}
246
+ </span>
247
+ </button>
248
  <CategoryLegend darkMode={darkMode} />
249
  <ZoomControls />
250
  </div>
 
253
  <svg ref={svgRef} className="fontmap-svg"></svg>
254
  </div>
255
 
256
+ {selectedFont && (
257
+ <div className="focus-hint">
258
+ <div className="focus-hint-keys">
259
+ <kbd>←</kbd>
260
+ <kbd>↑</kbd>
261
+ <kbd>↓</kbd>
262
+ <kbd>→</kbd>
263
+ </div>
264
+ <span className="focus-hint-label">use arrow keys to navigate</span>
265
+ </div>
266
+ )}
267
+
268
  {!loading && fonts.length > 0 && (
269
  <TooltipManager
270
  selectedFont={selectedFont}
 
272
  darkMode={darkMode}
273
  onFontHover={handleFontHover}
274
  onFontUnhover={handleFontUnhover}
275
+ isMobile={isMobile}
276
+ onOpenFont={handleFontSelect}
277
  />
278
  )}
279
  </div>
src/components/FontMap/components/AboutModal.js CHANGED
@@ -142,7 +142,7 @@ const AboutModal = ({ onClose, darkMode }) => {
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>
 
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 uses <strong>FontCLIP</strong> embeddings &mdash; a CLIP model fine-tuned specifically for typography &mdash; to lay out fonts by visual style.
146
  </p>
147
  </div>
148
  </div>
src/components/FontMap/components/ActiveFont.js CHANGED
@@ -163,6 +163,17 @@ const ActiveFont = ({ selectedFont, fonts, darkMode, onClose, onFontSelect }) =>
163
 
164
  return (
165
  <div className="font-details">
 
 
 
 
 
 
 
 
 
 
 
166
  <div className="font-details-content">
167
  {/* Label Active Font en dehors de la carte */}
168
  <div className="active-font-label">Active Font</div>
 
163
 
164
  return (
165
  <div className="font-details">
166
+ <button
167
+ type="button"
168
+ className="font-details-back"
169
+ onClick={onClose}
170
+ aria-label="Back to map"
171
+ >
172
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
173
+ <polyline points="15 18 9 12 15 6" />
174
+ </svg>
175
+ <span>Back</span>
176
+ </button>
177
  <div className="font-details-content">
178
  {/* Label Active Font en dehors de la carte */}
179
  <div className="active-font-label">Active Font</div>
src/components/FontMap/components/TooltipManager.js CHANGED
@@ -5,10 +5,12 @@ import { useTooltipOptimized } from '../hooks/useTooltipOptimized';
5
  * Composant simplifié pour gérer les tooltips
6
  * Utilise le hook useTooltip pour une gestion propre et centralisée
7
  */
8
- const TooltipManager = ({
9
- selectedFont,
10
- hoveredFont,
11
- darkMode
 
 
12
  }) => {
13
  const {
14
  handleFontSelect,
@@ -16,7 +18,7 @@ const TooltipManager = ({
16
  handleFontUnhover,
17
  updateTransform,
18
  updatePositions
19
- } = useTooltipOptimized(darkMode);
20
 
21
  // Gérer la police sélectionnée
22
  useEffect(() => {
 
5
  * Composant simplifié pour gérer les tooltips
6
  * Utilise le hook useTooltip pour une gestion propre et centralisée
7
  */
8
+ const TooltipManager = ({
9
+ selectedFont,
10
+ hoveredFont,
11
+ darkMode,
12
+ isMobile,
13
+ onOpenFont,
14
  }) => {
15
  const {
16
  handleFontSelect,
 
18
  handleFontUnhover,
19
  updateTransform,
20
  updatePositions
21
+ } = useTooltipOptimized(darkMode, isMobile, onOpenFont);
22
 
23
  // Gérer la police sélectionnée
24
  useEffect(() => {
src/components/FontMap/hooks/useArrowNavigation.js CHANGED
@@ -106,12 +106,19 @@ export const useArrowNavigation = (
106
  return; // Ne pas intercepter les flèches dans les champs de saisie
107
  }
108
 
 
 
 
 
 
 
 
109
  // Gérer les touches fléchées
110
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
111
  event.preventDefault(); // Empêcher le scroll de la page
112
  findNearestFontInDirection(event.key);
113
  }
114
- }, [selectedFont, findNearestFontInDirection]);
115
 
116
  // Ajouter l'écouteur d'événements
117
  useEffect(() => {
 
106
  return; // Ne pas intercepter les flèches dans les champs de saisie
107
  }
108
 
109
+ // Quitter le mode focus
110
+ if (event.key === 'Escape') {
111
+ event.preventDefault();
112
+ onFontSelect(null);
113
+ return;
114
+ }
115
+
116
  // Gérer les touches fléchées
117
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
118
  event.preventDefault(); // Empêcher le scroll de la page
119
  findNearestFontInDirection(event.key);
120
  }
121
+ }, [selectedFont, findNearestFontInDirection, onFontSelect]);
122
 
123
  // Ajouter l'écouteur d'événements
124
  useEffect(() => {
src/components/FontMap/hooks/useFontMapTweakpane.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { Pane } from 'tweakpane';
3
+ import { useFontMapStore } from '../../../store/fontMapStore';
4
+
5
+ /**
6
+ * Floating Tweakpane panel for FontMap — controls overlap removal parameters.
7
+ */
8
+ export function useFontMapTweakpane() {
9
+ const overlapCollideRadius = useFontMapStore(s => s.overlapCollideRadius);
10
+ const overlapTicks = useFontMapStore(s => s.overlapTicks);
11
+ const overlapOriginStrength = useFontMapStore(s => s.overlapOriginStrength);
12
+ const setOverlapCollideRadius = useFontMapStore(s => s.setOverlapCollideRadius);
13
+ const setOverlapTicks = useFontMapStore(s => s.setOverlapTicks);
14
+ const setOverlapOriginStrength = useFontMapStore(s => s.setOverlapOriginStrength);
15
+
16
+ const paneRef = useRef(null);
17
+ const bindingsRef = useRef({});
18
+
19
+ useEffect(() => {
20
+ const pane = new Pane({ title: 'Layout', expanded: true });
21
+ paneRef.current = pane;
22
+
23
+ const params = {
24
+ overlapCollideRadius,
25
+ overlapTicks,
26
+ overlapOriginStrength,
27
+ };
28
+
29
+ const folder = pane.addFolder({ title: 'Overlap Removal' });
30
+
31
+ bindingsRef.current.radius = folder
32
+ .addBinding(params, 'overlapCollideRadius', {
33
+ label: 'Radius (px)',
34
+ min: 0,
35
+ max: 80,
36
+ step: 1,
37
+ })
38
+ .on('change', ev => setOverlapCollideRadius(ev.value));
39
+
40
+ bindingsRef.current.ticks = folder
41
+ .addBinding(params, 'overlapTicks', {
42
+ label: 'Ticks',
43
+ min: 0,
44
+ max: 300,
45
+ step: 10,
46
+ })
47
+ .on('change', ev => setOverlapTicks(ev.value));
48
+
49
+ bindingsRef.current.originStrength = folder
50
+ .addBinding(params, 'overlapOriginStrength', {
51
+ label: 'Pull to origin',
52
+ min: 0,
53
+ max: 0.5,
54
+ step: 0.01,
55
+ })
56
+ .on('change', ev => setOverlapOriginStrength(ev.value));
57
+
58
+ return () => {
59
+ pane.dispose();
60
+ paneRef.current = null;
61
+ bindingsRef.current = {};
62
+ };
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ const b = bindingsRef.current.radius;
68
+ if (b && b.value !== overlapCollideRadius) {
69
+ try { b.value = overlapCollideRadius; } catch (_) {}
70
+ }
71
+ }, [overlapCollideRadius]);
72
+
73
+ useEffect(() => {
74
+ const b = bindingsRef.current.ticks;
75
+ if (b && b.value !== overlapTicks) {
76
+ try { b.value = overlapTicks; } catch (_) {}
77
+ }
78
+ }, [overlapTicks]);
79
+
80
+ useEffect(() => {
81
+ const b = bindingsRef.current.originStrength;
82
+ if (b && b.value !== overlapOriginStrength) {
83
+ try { b.value = overlapOriginStrength; } catch (_) {}
84
+ }
85
+ }, [overlapOriginStrength]);
86
+ }
src/components/FontMap/hooks/useMapRenderer.js CHANGED
@@ -45,7 +45,7 @@ function getOrCreateViewportGroup(svg) {
45
  /**
46
  * Hook de rendu — utilise le sprite SVG pré-chargé (0 requête réseau).
47
  */
48
- export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm, darkMode, loading, enabled = true }) {
49
  const mappingRef = useRef({ mapX: null, mapY: null });
50
  const dimensionsRef = useRef({ width: 0, height: 0 });
51
  const hasRenderedRef = useRef(false);
@@ -55,7 +55,7 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
55
  selectedFont,
56
  setSelectedFont,
57
  setHoveredFont,
58
- useCategoryColors
59
  } = useFontMapStore();
60
 
61
  selectedFontRef.current = selectedFont;
@@ -88,7 +88,15 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
88
  const ns = 'http://www.w3.org/2000/svg';
89
 
90
  fonts.forEach(font => {
91
- const pathD = hasSprite ? (glyphPaths[`${font.id}_a`] || glyphPaths[font.id]) : null;
 
 
 
 
 
 
 
 
92
  if (!pathD) return;
93
 
94
  const x = mapX(font.x);
@@ -105,9 +113,19 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
105
  g.setAttribute('class', 'glyph-group');
106
  g.style.cursor = 'pointer';
107
 
 
 
 
 
 
 
 
 
 
108
  const path = document.createElementNS(ns, 'path');
109
  path.setAttribute('d', pathD);
110
  path.setAttribute('fill', color);
 
111
  g.appendChild(path);
112
 
113
  vgNode.appendChild(g);
@@ -117,18 +135,16 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
117
  }, [enabled, fonts, glyphPaths, darkMode, useCategoryColors, svgRef]);
118
 
119
  // ── Mise à jour des couleurs (dark mode / category colors toggle) ──
 
 
120
  useEffect(() => {
121
  if (!svgRef.current) return;
122
- const viewportGroup = svgRef.current.querySelector('.viewport-group');
123
- if (!viewportGroup) return;
124
 
125
- viewportGroup.querySelectorAll('g.glyph-group').forEach(group => {
126
  const category = group.getAttribute('data-category');
127
  const color = getGlyphColor(category, useCategoryColors, darkMode);
128
- group.querySelectorAll('*').forEach(el => {
129
- if (el.nodeType === Node.ELEMENT_NODE) {
130
- el.setAttribute('fill', color);
131
- }
132
  });
133
  });
134
  }, [darkMode, useCategoryColors, svgRef]);
@@ -166,6 +182,7 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
166
  const fillColor = useCategoryColors
167
  ? null
168
  : (darkMode ? '#ffffff' : '#000000');
 
169
 
170
  Object.entries(centroids).forEach(([cat, c]) => {
171
  const x = mapX(c.x / c.n);
@@ -179,7 +196,7 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
179
  .attr('font-size', '16px')
180
  .attr('font-weight', 'bold')
181
  .attr('fill', color)
182
- .attr('stroke', '#ffffff')
183
  .attr('stroke-width', '8px')
184
  .attr('paint-order', 'stroke fill')
185
  .attr('class', 'centroid-label')
@@ -282,6 +299,9 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
282
  };
283
 
284
  const handleMouseOut = (e) => {
 
 
 
285
  if (useFontMapStore.getState().isTransitioning) return;
286
  const group = findGlyphGroup(e.target);
287
  if (!group) return;
@@ -291,15 +311,25 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
291
  const handleClick = (e) => {
292
  const group = findGlyphGroup(e.target);
293
  if (!group) {
294
- if (selectedFontRef.current) setSelectedFont(null);
 
 
 
 
 
295
  return;
296
  }
297
  const font = getFontFromGroup(group);
298
- if (font) {
299
- setHoveredFont(null);
300
- const cur = selectedFontRef.current;
301
- setSelectedFont(cur && cur.id === font.id ? null : font);
 
 
302
  }
 
 
 
303
  };
304
 
305
  svg.addEventListener('mouseover', handleMouseOver);
@@ -311,7 +341,7 @@ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm,
311
  svg.removeEventListener('mouseout', handleMouseOut);
312
  svg.removeEventListener('click', handleClick);
313
  };
314
- }, [fonts, setSelectedFont, setHoveredFont, svgRef]);
315
 
316
  return { mappingRef, dimensionsRef };
317
  }
 
45
  /**
46
  * Hook de rendu — utilise le sprite SVG pré-chargé (0 requête réseau).
47
  */
48
+ export function useMapRenderer({ svgRef, fonts, glyphPaths, filter, searchTerm, darkMode, loading, enabled = true, isMobile = false }) {
49
  const mappingRef = useRef({ mapX: null, mapY: null });
50
  const dimensionsRef = useRef({ width: 0, height: 0 });
51
  const hasRenderedRef = useRef(false);
 
55
  selectedFont,
56
  setSelectedFont,
57
  setHoveredFont,
58
+ useCategoryColors,
59
  } = useFontMapStore();
60
 
61
  selectedFontRef.current = selectedFont;
 
88
  const ns = 'http://www.w3.org/2000/svg';
89
 
90
  fonts.forEach(font => {
91
+ // The sprite is keyed off the slugified font name (often imageName),
92
+ // not always the short id. Fall back to the imageName-derived key.
93
+ const imgKey = (font.imageName || font.name || '').toLowerCase();
94
+ const pathD = hasSprite
95
+ ? (glyphPaths[`${font.id}_a`]
96
+ || glyphPaths[font.id]
97
+ || glyphPaths[`${imgKey}_a`]
98
+ || glyphPaths[imgKey])
99
+ : null;
100
  if (!pathD) return;
101
 
102
  const x = mapX(font.x);
 
113
  g.setAttribute('class', 'glyph-group');
114
  g.style.cursor = 'pointer';
115
 
116
+ const hitbox = document.createElementNS(ns, 'circle');
117
+ hitbox.setAttribute('class', 'glyph-hitbox');
118
+ hitbox.setAttribute('cx', '40');
119
+ hitbox.setAttribute('cy', '40');
120
+ hitbox.setAttribute('r', '44');
121
+ hitbox.setAttribute('fill', 'transparent');
122
+ hitbox.setAttribute('pointer-events', 'all');
123
+ g.appendChild(hitbox);
124
+
125
  const path = document.createElementNS(ns, 'path');
126
  path.setAttribute('d', pathD);
127
  path.setAttribute('fill', color);
128
+ path.setAttribute('pointer-events', 'none');
129
  g.appendChild(path);
130
 
131
  vgNode.appendChild(g);
 
135
  }, [enabled, fonts, glyphPaths, darkMode, useCategoryColors, svgRef]);
136
 
137
  // ── Mise à jour des couleurs (dark mode / category colors toggle) ──
138
+ // On itère sur tous les g.glyph-group du SVG (viewport + highlight clone)
139
+ // pour que la lettre en focus suive aussi les toggles.
140
  useEffect(() => {
141
  if (!svgRef.current) return;
 
 
142
 
143
+ svgRef.current.querySelectorAll('g.glyph-group').forEach(group => {
144
  const category = group.getAttribute('data-category');
145
  const color = getGlyphColor(category, useCategoryColors, darkMode);
146
+ group.querySelectorAll('path').forEach(el => {
147
+ el.setAttribute('fill', color);
 
 
148
  });
149
  });
150
  }, [darkMode, useCategoryColors, svgRef]);
 
182
  const fillColor = useCategoryColors
183
  ? null
184
  : (darkMode ? '#ffffff' : '#000000');
185
+ const haloColor = darkMode ? '#000000' : '#ffffff';
186
 
187
  Object.entries(centroids).forEach(([cat, c]) => {
188
  const x = mapX(c.x / c.n);
 
196
  .attr('font-size', '16px')
197
  .attr('font-weight', 'bold')
198
  .attr('fill', color)
199
+ .attr('stroke', haloColor)
200
  .attr('stroke-width', '8px')
201
  .attr('paint-order', 'stroke fill')
202
  .attr('class', 'centroid-label')
 
299
  };
300
 
301
  const handleMouseOut = (e) => {
302
+ // Sur mobile, le tap synthétise des mouseover/mouseout — on ignore le
303
+ // out pour que le tooltip reste ouvert jusqu'au prochain tap.
304
+ if (isMobile) return;
305
  if (useFontMapStore.getState().isTransitioning) return;
306
  const group = findGlyphGroup(e.target);
307
  if (!group) return;
 
311
  const handleClick = (e) => {
312
  const group = findGlyphGroup(e.target);
313
  if (!group) {
314
+ // Tap on empty space — clear hover (mobile) or selection (desktop)
315
+ if (isMobile) {
316
+ setHoveredFont(null);
317
+ } else if (selectedFontRef.current) {
318
+ setSelectedFont(null);
319
+ }
320
  return;
321
  }
322
  const font = getFontFromGroup(group);
323
+ if (!font) return;
324
+ if (isMobile) {
325
+ // Mobile: tap shows the tooltip with an Open button — don't open the
326
+ // drawer directly. The button inside the tooltip selects the font.
327
+ setHoveredFont(font);
328
+ return;
329
  }
330
+ setHoveredFont(null);
331
+ const cur = selectedFontRef.current;
332
+ setSelectedFont(cur && cur.id === font.id ? null : font);
333
  };
334
 
335
  svg.addEventListener('mouseover', handleMouseOver);
 
341
  svg.removeEventListener('mouseout', handleMouseOut);
342
  svg.removeEventListener('click', handleClick);
343
  };
344
+ }, [fonts, setSelectedFont, setHoveredFont, svgRef, isMobile]);
345
 
346
  return { mappingRef, dimensionsRef };
347
  }
src/components/FontMap/hooks/useMapZoom.js CHANGED
@@ -99,7 +99,7 @@ export function useMapZoom(svgRef, enabled = true) {
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
 
 
99
  const width = svgNode.clientWidth || svgNode.getBoundingClientRect().width;
100
  const height = svgNode.clientHeight || svgNode.getBoundingClientRect().height;
101
 
102
+ const scale = 4.0;
103
  const translateX = width / 2 - fontX * scale;
104
  const translateY = height / 2 - fontY * scale;
105
 
src/components/FontMap/hooks/useTooltipOptimized.js CHANGED
@@ -5,11 +5,14 @@ import * as d3 from 'd3';
5
  * Hook optimisé pour la gestion des tooltips
6
  * Séparation claire entre logique de positionnement et affichage
7
  */
8
- export const useTooltipOptimized = (darkMode) => {
9
  const selectedTooltipRef = useRef(null);
10
  const hoverTooltipRef = useRef(null);
11
  const currentTransformRef = useRef(d3.zoomIdentity);
12
  const imageLoadTimeoutsRef = useRef(new Map());
 
 
 
13
 
14
  // Mémoriser les styles du tooltip selon le mode sombre
15
  const tooltipStyles = useMemo(() => ({
@@ -47,7 +50,20 @@ export const useTooltipOptimized = (darkMode) => {
47
  .style('z-index', 1000)
48
  .style('transition', 'opacity 0.2s ease');
49
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  return () => {
 
51
  d3.selectAll('.font-tooltip').remove();
52
  // Nettoyer les timeouts
53
  const currentTimeouts = imageLoadTimeoutsRef.current;
@@ -75,33 +91,37 @@ export const useTooltipOptimized = (darkMode) => {
75
  }, [darkMode, tooltipStyles]);
76
 
77
  // Fonction optimisée pour créer le contenu du tooltip
 
 
78
  const createTooltipContent = useCallback((font) => {
79
  const imageName = font.imageName || font.name;
80
  const sentenceImagePath = `/data/sentences/${imageName.toLowerCase().replace(/\s+/g, '_')}_sentence.svg`;
81
- const textColor = darkMode ? '#ffffff' : '#000000';
82
- const imageFilter = darkMode ? 'invert(1)' : 'none';
83
-
 
84
  return `
85
  <div class="simple-tooltip">
86
- <div class="tooltip-font-name" style="color: ${textColor} !important;">${font.name}</div>
87
  <div class="tooltip-sentence-preview">
88
  <div class="tooltip-image-container" style="position: relative; min-height: 44px; display: flex; align-items: center; justify-content: center;">
89
- <img src="${sentenceImagePath}"
90
- alt="${font.name} sentence preview"
91
  class="sentence-image"
92
- style="filter: ${imageFilter}; max-width: 200px; height: auto; opacity: 0; transition: opacity 0.2s ease;"
93
  onload="this.style.opacity='1'; this.parentElement.querySelector('.tooltip-spinner').style.display='none';"
94
  onerror="this.style.display='none'; this.parentElement.querySelector('.tooltip-spinner').style.display='flex'; this.parentElement.querySelector('.tooltip-spinner').innerHTML='⚠️ Loading error';"
95
  />
96
- <div class="tooltip-spinner" style="display: flex; align-items: center; justify-content: center; color: #999; font-size: 12px; position: absolute; top: 0; left: 0; right: 0; bottom: 0;">
97
  <div style="width: 16px; height: 16px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>
98
  Loading...
99
  </div>
100
  </div>
101
  </div>
 
102
  </div>
103
  `;
104
- }, [darkMode]);
105
 
106
  // Fonction optimisée pour positionner un tooltip
107
  const positionTooltip = useCallback((tooltip, svgElement) => {
@@ -143,6 +163,8 @@ export const useTooltipOptimized = (darkMode) => {
143
  const showTooltip = useCallback((tooltip, font, svgElement) => {
144
  if (!tooltip || !font) return;
145
 
 
 
146
  tooltip
147
  .html(createTooltipContent(font))
148
  .style('opacity', 1);
 
5
  * Hook optimisé pour la gestion des tooltips
6
  * Séparation claire entre logique de positionnement et affichage
7
  */
8
+ export const useTooltipOptimized = (darkMode, isMobile = false, onOpenFont = null) => {
9
  const selectedTooltipRef = useRef(null);
10
  const hoverTooltipRef = useRef(null);
11
  const currentTransformRef = useRef(d3.zoomIdentity);
12
  const imageLoadTimeoutsRef = useRef(new Map());
13
+ const currentFontRef = useRef(null);
14
+ const onOpenFontRef = useRef(onOpenFont);
15
+ useEffect(() => { onOpenFontRef.current = onOpenFont; }, [onOpenFont]);
16
 
17
  // Mémoriser les styles du tooltip selon le mode sombre
18
  const tooltipStyles = useMemo(() => ({
 
50
  .style('z-index', 1000)
51
  .style('transition', 'opacity 0.2s ease');
52
 
53
+ // Délégation de clic pour le bouton "Open" (mobile uniquement) — reste
54
+ // armé en permanence, le bouton n'existe dans le HTML que sur mobile.
55
+ const onHoverClick = (e) => {
56
+ if (e.target.closest('.tooltip-open-btn')) {
57
+ const font = currentFontRef.current;
58
+ const cb = onOpenFontRef.current;
59
+ if (font && cb) cb(font);
60
+ }
61
+ };
62
+ const hoverNode = hoverTooltipRef.current.node();
63
+ if (hoverNode) hoverNode.addEventListener('click', onHoverClick);
64
+
65
  return () => {
66
+ if (hoverNode) hoverNode.removeEventListener('click', onHoverClick);
67
  d3.selectAll('.font-tooltip').remove();
68
  // Nettoyer les timeouts
69
  const currentTimeouts = imageLoadTimeoutsRef.current;
 
91
  }, [darkMode, tooltipStyles]);
92
 
93
  // Fonction optimisée pour créer le contenu du tooltip
94
+ // Pas de couleurs inline — tout est piloté par CSS via .font-tooltip.dark-mode
95
+ // pour que le toggle dark/light reste cohérent même si le tooltip est ouvert.
96
  const createTooltipContent = useCallback((font) => {
97
  const imageName = font.imageName || font.name;
98
  const sentenceImagePath = `/data/sentences/${imageName.toLowerCase().replace(/\s+/g, '_')}_sentence.svg`;
99
+ const openButton = isMobile
100
+ ? `<button type="button" class="tooltip-open-btn">Open</button>`
101
+ : '';
102
+
103
  return `
104
  <div class="simple-tooltip">
105
+ <div class="tooltip-font-name">${font.name}</div>
106
  <div class="tooltip-sentence-preview">
107
  <div class="tooltip-image-container" style="position: relative; min-height: 44px; display: flex; align-items: center; justify-content: center;">
108
+ <img src="${sentenceImagePath}"
109
+ alt=""
110
  class="sentence-image"
111
+ style="max-width: 200px; height: auto; opacity: 0; transition: opacity 0.2s ease;"
112
  onload="this.style.opacity='1'; this.parentElement.querySelector('.tooltip-spinner').style.display='none';"
113
  onerror="this.style.display='none'; this.parentElement.querySelector('.tooltip-spinner').style.display='flex'; this.parentElement.querySelector('.tooltip-spinner').innerHTML='⚠️ Loading error';"
114
  />
115
+ <div class="tooltip-spinner" style="display: flex; align-items: center; justify-content: center; font-size: 12px; position: absolute; top: 0; left: 0; right: 0; bottom: 0;">
116
  <div style="width: 16px; height: 16px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>
117
  Loading...
118
  </div>
119
  </div>
120
  </div>
121
+ ${openButton}
122
  </div>
123
  `;
124
+ }, [isMobile]);
125
 
126
  // Fonction optimisée pour positionner un tooltip
127
  const positionTooltip = useCallback((tooltip, svgElement) => {
 
163
  const showTooltip = useCallback((tooltip, font, svgElement) => {
164
  if (!tooltip || !font) return;
165
 
166
+ if (tooltip === hoverTooltipRef.current) currentFontRef.current = font;
167
+
168
  tooltip
169
  .html(createTooltipContent(font))
170
  .style('opacity', 1);
src/components/FontMap/styles/controls.css CHANGED
@@ -385,6 +385,92 @@
385
  gap: 8px;
386
  }
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  /* Zoom Controls */
389
  .zoom-controls {
390
  display: flex;
 
385
  gap: 8px;
386
  }
387
 
388
+ /* Focus mode hint (shown when a font is selected) */
389
+ .focus-hint {
390
+ position: absolute;
391
+ bottom: 16px;
392
+ left: 16px;
393
+ z-index: 200;
394
+ height: 36px;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 12px;
398
+ background: transparent;
399
+ pointer-events: none;
400
+ user-select: none;
401
+ }
402
+
403
+ .focus-hint-keys {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: 4px;
407
+ }
408
+
409
+ .focus-hint kbd {
410
+ display: inline-flex;
411
+ align-items: center;
412
+ justify-content: center;
413
+ width: 32px;
414
+ height: 32px;
415
+ border: 1px solid #ffffff;
416
+ border-radius: 4px;
417
+ background: #ffffff;
418
+ color: #000000;
419
+ font-family: inherit;
420
+ font-size: 14px;
421
+ line-height: 1;
422
+ }
423
+
424
+ .dark-mode .focus-hint kbd {
425
+ background: #000000;
426
+ color: #ffffff;
427
+ border-color: #ffffff;
428
+ }
429
+
430
+ .focus-hint-label {
431
+ color: #000000;
432
+ font-size: 12px;
433
+ letter-spacing: 0.3px;
434
+ line-height: 36px;
435
+ text-shadow: 0 0 3px #ffffff, 0 0 3px #ffffff, 0 0 3px #ffffff;
436
+ }
437
+
438
+ .dark-mode .focus-hint-label {
439
+ color: #ffffff;
440
+ text-shadow: 0 0 3px #000000, 0 0 3px #000000, 0 0 3px #000000;
441
+ }
442
+
443
+ /* Dark mode toggle button (lives inside .bottom-controls — styled like .category-legend) */
444
+ .bottom-controls .dark-mode-toggle {
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ padding: var(--spacing-sm) var(--spacing-md);
449
+ background: var(--color-bg-primary);
450
+ border: 1px solid var(--color-border-primary);
451
+ border-radius: var(--border-radius-md);
452
+ box-shadow: var(--shadow-sm);
453
+ color: var(--color-text-primary);
454
+ line-height: 1;
455
+ cursor: pointer;
456
+ font-family: inherit;
457
+ flex-shrink: 0;
458
+ }
459
+
460
+ .dark-mode .bottom-controls .dark-mode-toggle {
461
+ background: var(--color-bg-primary-dark);
462
+ border-color: var(--color-border-primary-dark);
463
+ color: var(--color-text-primary-dark);
464
+ box-shadow: 0 1px 3px rgba(255, 255, 255, 0.1);
465
+ }
466
+
467
+ .dark-mode-toggle-icon {
468
+ display: inline-flex;
469
+ align-items: center;
470
+ justify-content: center;
471
+ transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
472
+ }
473
+
474
  /* Zoom Controls */
475
  .zoom-controls {
476
  display: flex;
src/components/FontMap/styles/layout.css CHANGED
@@ -41,6 +41,7 @@
41
  flex-direction: column;
42
  overflow: hidden;
43
  position: relative;
 
44
  }
45
 
46
  .dark-mode .sidebar {
@@ -228,6 +229,11 @@
228
  cursor: grabbing;
229
  }
230
 
 
 
 
 
 
231
  /* Font glyphs with expanded hit area */
232
  .font-glyph-group,
233
  .glyph-group {
@@ -238,6 +244,26 @@
238
  cursor: pointer;
239
  }
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  .font-glyph {
242
  pointer-events: none;
243
  shape-rendering: geometricPrecision;
 
41
  flex-direction: column;
42
  overflow: hidden;
43
  position: relative;
44
+ z-index: 2000;
45
  }
46
 
47
  .dark-mode .sidebar {
 
229
  cursor: grabbing;
230
  }
231
 
232
+ /* Mobile-only back chevron inside the ActiveFont drawer (hidden on desktop) */
233
+ .font-details-back {
234
+ display: none;
235
+ }
236
+
237
  /* Font glyphs with expanded hit area */
238
  .font-glyph-group,
239
  .glyph-group {
 
244
  cursor: pointer;
245
  }
246
 
247
+ .glyph-hitbox {
248
+ fill: transparent;
249
+ stroke: none;
250
+ vector-effect: non-scaling-stroke;
251
+ }
252
+
253
+ .glyph-hitbox:hover,
254
+ .highlight-group .glyph-hitbox {
255
+ fill: rgba(0, 0, 0, 0.02);
256
+ stroke: rgba(0, 0, 0, 0.14);
257
+ stroke-width: 1;
258
+ }
259
+
260
+ .dark-mode .glyph-hitbox:hover,
261
+ .dark-mode .highlight-group .glyph-hitbox {
262
+ fill: rgba(255, 255, 255, 0.03);
263
+ stroke: rgba(255, 255, 255, 0.18);
264
+ stroke-width: 1;
265
+ }
266
+
267
  .font-glyph {
268
  pointer-events: none;
269
  shape-rendering: geometricPrecision;
src/components/FontMap/styles/responsive.css CHANGED
@@ -11,24 +11,94 @@
11
  .fontmap-container {
12
  flex-direction: column;
13
  }
14
-
 
15
  .sidebar {
 
 
 
 
16
  width: 100%;
17
- min-width: 100%;
18
- height: 40vh;
19
- max-height: 40vh;
20
  border-right: none;
21
  border-bottom: 1px solid var(--color-border-primary);
 
 
22
  }
23
-
24
  .dark-mode .sidebar {
25
  border-bottom-color: var(--color-border-primary-dark);
26
  }
27
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  .main-area {
29
- height: 60vh;
 
30
  }
31
-
32
  .sidebar-header {
33
  padding: var(--spacing-md);
34
  flex-direction: row;
@@ -120,15 +190,6 @@
120
  }
121
 
122
  @media (max-width: 480px) {
123
- .sidebar {
124
- height: 35vh;
125
- max-height: 35vh;
126
- }
127
-
128
- .main-area {
129
- height: 65vh;
130
- }
131
-
132
  .sidebar-header {
133
  padding: var(--spacing-sm);
134
  flex-direction: column;
 
11
  .fontmap-container {
12
  flex-direction: column;
13
  }
14
+
15
+ /* Sidebar becomes a sticky top strip with just search + filters */
16
  .sidebar {
17
+ position: fixed;
18
+ top: 0;
19
+ left: 0;
20
+ right: 0;
21
  width: 100%;
22
+ min-width: 0;
23
+ height: auto;
24
+ max-height: none;
25
  border-right: none;
26
  border-bottom: 1px solid var(--color-border-primary);
27
+ overflow: visible;
28
+ z-index: 2000;
29
  }
30
+
31
  .dark-mode .sidebar {
32
  border-bottom-color: var(--color-border-primary-dark);
33
  }
34
+
35
+ .sidebar-content {
36
+ overflow: visible;
37
+ padding: 0;
38
+ }
39
+
40
+ .sidebar-footer {
41
+ display: none;
42
+ }
43
+
44
+ /* Hide focus hint and bottom controls on mobile */
45
+ .focus-hint,
46
+ .bottom-controls {
47
+ display: none !important;
48
+ }
49
+
50
+ /* ActiveFont becomes a slide-in drawer when a font is selected */
51
+ .font-details {
52
+ position: fixed;
53
+ top: 0;
54
+ right: 0;
55
+ bottom: 0;
56
+ width: 100%;
57
+ height: 100vh;
58
+ background: var(--color-bg-primary);
59
+ z-index: 3000;
60
+ overflow-y: auto;
61
+ transform: translateX(100%);
62
+ transition: transform 0.28s ease;
63
+ padding-top: 48px;
64
+ }
65
+
66
+ .dark-mode .font-details {
67
+ background: var(--color-bg-primary-dark);
68
+ }
69
+
70
+ .fontmap-container.has-focus .font-details {
71
+ transform: translateX(0);
72
+ }
73
+
74
+ /* Mobile back chevron — visible only on mobile inside the drawer */
75
+ .font-details-back {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ gap: 4px;
79
+ position: absolute;
80
+ top: 12px;
81
+ left: 12px;
82
+ padding: 6px 10px 6px 6px;
83
+ border: none;
84
+ background: transparent;
85
+ color: var(--color-text-primary);
86
+ font-family: inherit;
87
+ font-size: 13px;
88
+ cursor: pointer;
89
+ z-index: 1;
90
+ }
91
+
92
+ .dark-mode .font-details-back {
93
+ color: var(--color-text-primary-dark);
94
+ }
95
+
96
+ /* Map fills full viewport beneath the sticky sidebar */
97
  .main-area {
98
+ height: 100vh;
99
+ width: 100%;
100
  }
101
+
102
  .sidebar-header {
103
  padding: var(--spacing-md);
104
  flex-direction: row;
 
190
  }
191
 
192
  @media (max-width: 480px) {
 
 
 
 
 
 
 
 
 
193
  .sidebar-header {
194
  padding: var(--spacing-sm);
195
  flex-direction: column;
src/components/FontMap/styles/tooltip.css CHANGED
@@ -50,6 +50,34 @@
50
  max-width: 280px;
51
  overflow: hidden;
52
  transform: translateY(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  /* Tooltip sélectionné (priorité normale) */
@@ -272,10 +300,20 @@
272
 
273
 
274
  /* Dark mode overrides for tooltip images */
275
- .dark-mode .tooltip-sentence img {
 
276
  filter: invert(1) !important;
277
  }
278
 
 
 
 
 
 
 
 
 
 
279
  /* Dark mode overrides for tooltip SVG */
280
  .dark-mode .tooltip-fallback svg use {
281
  fill: #ffffff !important;
 
50
  max-width: 280px;
51
  overflow: hidden;
52
  transform: translateY(0);
53
+ user-select: none;
54
+ -webkit-user-select: none;
55
+ pointer-events: none;
56
+ }
57
+
58
+ /* Mobile-only "Open" button inside the hover tooltip */
59
+ .tooltip-open-btn {
60
+ display: block;
61
+ width: calc(100% - 16px);
62
+ margin: 0 8px 8px;
63
+ padding: 8px 12px;
64
+ border: 1px solid var(--color-border-primary);
65
+ border-radius: var(--border-radius-sm);
66
+ background: var(--color-bg-primary);
67
+ color: var(--color-text-primary);
68
+ font-family: inherit;
69
+ font-size: 12px;
70
+ font-weight: 500;
71
+ letter-spacing: 0.3px;
72
+ text-transform: uppercase;
73
+ cursor: pointer;
74
+ pointer-events: auto;
75
+ }
76
+
77
+ .font-tooltip.dark-mode .tooltip-open-btn {
78
+ background: var(--color-bg-primary-dark);
79
+ color: var(--color-text-primary-dark);
80
+ border-color: var(--color-border-primary-dark);
81
  }
82
 
83
  /* Tooltip sélectionné (priorité normale) */
 
300
 
301
 
302
  /* Dark mode overrides for tooltip images */
303
+ .dark-mode .tooltip-sentence img,
304
+ .font-tooltip.dark-mode .sentence-image {
305
  filter: invert(1) !important;
306
  }
307
 
308
+ /* Spinner inherits tooltip color so it adapts to dark mode */
309
+ .font-tooltip .tooltip-spinner {
310
+ color: var(--color-text-secondary) !important;
311
+ }
312
+
313
+ .font-tooltip.dark-mode .tooltip-spinner {
314
+ color: var(--color-text-secondary-dark) !important;
315
+ }
316
+
317
  /* Dark mode overrides for tooltip SVG */
318
  .dark-mode .tooltip-fallback svg use {
319
  fill: #ffffff !important;
src/components/FontMap/utils/voronoiDilation.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as d3 from 'd3';
2
+
3
+ /**
4
+ * Overlap removal via D3 force simulation — minimum displacement strategy.
5
+ *
6
+ * forceCollide only pushes pairs that actually overlap; points already well
7
+ * separated don't move at all. A weak forceX/forceY pulls every point back
8
+ * toward its original UMAP position, so the layout drifts as little as possible.
9
+ *
10
+ * @param {Array<{id: string, x: number, y: number}>} points - initial screen-space positions
11
+ * @param {Object} opts
12
+ * @param {number} opts.collideRadius - collision radius per glyph in pixels
13
+ * @param {number} opts.ticks - number of simulation steps (more = fuller resolution)
14
+ * @param {number} opts.originStrength - pull-back toward UMAP origin [0, 1] (keep small, e.g. 0.1)
15
+ * @returns {Array<{id: string, x: number, y: number}>}
16
+ */
17
+ export function applyOverlapRemoval(points, { collideRadius = 10, ticks = 140, originStrength = 0.03 }) {
18
+ if (ticks === 0 || collideRadius === 0 || points.length < 2) return points;
19
+
20
+ // d3 forceSimulation mutates node objects in-place
21
+ const nodes = points.map(p => ({ id: p.id, x: p.x, y: p.y, ox: p.x, oy: p.y }));
22
+
23
+ const sim = d3.forceSimulation(nodes)
24
+ .force('collide', d3.forceCollide(collideRadius).strength(1).iterations(3))
25
+ .force('x', d3.forceX(n => n.ox).strength(originStrength))
26
+ .force('y', d3.forceY(n => n.oy).strength(originStrength))
27
+ .stop();
28
+
29
+ // Run synchronously (no animation loop needed)
30
+ for (let i = 0; i < ticks; i++) sim.tick();
31
+
32
+ return nodes.map(n => ({ id: n.id, x: n.x, y: n.y }));
33
+ }
src/hooks/useMediaQuery.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Subscribe to a CSS media query and re-render when it flips.
5
+ * Cheap: one matchMedia listener for the lifetime of the consumer.
6
+ */
7
+ export function useMediaQuery(query) {
8
+ const [matches, setMatches] = useState(() =>
9
+ typeof window !== 'undefined' && window.matchMedia
10
+ ? window.matchMedia(query).matches
11
+ : false,
12
+ );
13
+
14
+ useEffect(() => {
15
+ if (typeof window === 'undefined' || !window.matchMedia) return;
16
+ const mql = window.matchMedia(query);
17
+ const onChange = (e) => setMatches(e.matches);
18
+ setMatches(mql.matches);
19
+ mql.addEventListener('change', onChange);
20
+ return () => mql.removeEventListener('change', onChange);
21
+ }, [query]);
22
+
23
+ return matches;
24
+ }
src/store/fontMapStore.js CHANGED
@@ -43,7 +43,7 @@ export const useFontMapStore = create((set, get) => ({
43
  },
44
 
45
  setUseCategoryColors: (val) => set({ useCategoryColors: val }),
46
-
47
  setIsTransitioning: (val) => set({ isTransitioning: val }),
48
 
49
  // Actions pour le debug
 
43
  },
44
 
45
  setUseCategoryColors: (val) => set({ useCategoryColors: val }),
46
+
47
  setIsTransitioning: (val) => set({ isTransitioning: val }),
48
 
49
  // Actions pour le debug