feat: mobile UX overhaul + focus mode polish + visual hover hitbox
Browse filesMobile (<=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 +3 -1
- public/data/typography_data.json +0 -0
- scripts/apply-overlap-removal.mjs +129 -0
- src/components/DebugUMAP/components/LiveUMAPPanel.js +10 -1
- src/components/DebugUMAP/hooks/useGlyphRenderer.js +40 -11
- src/components/DebugUMAP/hooks/useLeva.js +24 -1
- src/components/DebugUMAP/store/useDebugUMAPStore.js +16 -0
- src/components/FontMap/FontMap.js +60 -7
- src/components/FontMap/components/AboutModal.js +1 -1
- src/components/FontMap/components/ActiveFont.js +11 -0
- src/components/FontMap/components/TooltipManager.js +7 -5
- src/components/FontMap/hooks/useArrowNavigation.js +8 -1
- src/components/FontMap/hooks/useFontMapTweakpane.js +86 -0
- src/components/FontMap/hooks/useMapRenderer.js +47 -17
- src/components/FontMap/hooks/useMapZoom.js +1 -1
- src/components/FontMap/hooks/useTooltipOptimized.js +32 -10
- src/components/FontMap/styles/controls.css +86 -0
- src/components/FontMap/styles/layout.css +26 -0
- src/components/FontMap/styles/responsive.css +78 -17
- src/components/FontMap/styles/tooltip.css +39 -1
- src/components/FontMap/utils/voronoiDilation.js +33 -0
- src/hooks/useMediaQuery.js +24 -0
- src/store/fontMapStore.js +1 -1
|
@@ -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": [
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const setMappingFunctions = useDebugUMAPStore((state) => state.setMappingFunctions);
|
| 20 |
-
const 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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);
|
|
@@ -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]);
|
|
@@ -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
|
|
@@ -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
|
|
|
|
| 81 |
useEffect(() => {
|
| 82 |
if (selectedFont) {
|
| 83 |
centerOnFont(selectedFont);
|
| 84 |
-
} else {
|
| 85 |
-
resetZoom();
|
| 86 |
}
|
| 87 |
-
}, [selectedFont, centerOnFont
|
| 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>
|
|
@@ -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
|
| 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 — a CLIP model fine-tuned specifically for typography — to lay out fonts by visual style.
|
| 146 |
</p>
|
| 147 |
</div>
|
| 148 |
</div>
|
|
@@ -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>
|
|
@@ -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(() => {
|
|
@@ -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(() => {
|
|
@@ -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 |
+
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 126 |
const category = group.getAttribute('data-category');
|
| 127 |
const color = getGlyphColor(category, useCategoryColors, darkMode);
|
| 128 |
-
group.querySelectorAll('
|
| 129 |
-
|
| 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',
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
return;
|
| 296 |
}
|
| 297 |
const font = getFontFromGroup(group);
|
| 298 |
-
if (font)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 =
|
| 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 |
|
|
@@ -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
|
| 82 |
-
|
| 83 |
-
|
|
|
|
| 84 |
return `
|
| 85 |
<div class="simple-tooltip">
|
| 86 |
-
<div class="tooltip-font-name"
|
| 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="
|
| 91 |
class="sentence-image"
|
| 92 |
-
style="
|
| 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;
|
| 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 |
-
}, [
|
| 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);
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -11,24 +11,94 @@
|
|
| 11 |
.fontmap-container {
|
| 12 |
flex-direction: column;
|
| 13 |
}
|
| 14 |
-
|
|
|
|
| 15 |
.sidebar {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
width: 100%;
|
| 17 |
-
min-width:
|
| 18 |
-
height:
|
| 19 |
-
max-height:
|
| 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:
|
|
|
|
| 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;
|
|
@@ -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;
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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
|