Sfoglia il codice sorgente

feat: Réorganiser la structure des fichiers JS, ajouter des tests unitaires et améliorer le chargement des scripts

Ynats 2 mesi fa
parent
commit
3d6a18306a

+ 134 - 1
.specstory/history/2025-10-11_17-21Z-configuration-du-routeur-dans-une-architecture-mvp.md

@@ -4049,7 +4049,7 @@ Souhaitez-vous que j'ajoute un style CSS pour limiter la largeur maximale du dro
 
 ---
 
-_**User (2025-10-13 07:39Z)**_
+_**User (2025-10-13 07:41Z)**_
 
 Fait un float right avec un margin-right de 5px
 
@@ -4058,6 +4058,139 @@ Fait un float right avec un margin-right de 5px
 _**Assistant (copilot/gpt-5-mini)**_
 
 
+```
+
+```
+C'est fait — j'ai ajouté un réglage CSS + JS pour garantir que le menu déroulant « Paramètres » reste visible à l'écran et qu'il ait une marge droite de 5px.
+
+Résumé des changements
+- CSS (public/assets/styles.css)
+  - Ajouté :
+    - .dropdown-menu.dropdown-menu-end { right: 5px !important; left: auto !important; }
+    - .dropdown-menu { margin-right: 5px; }
+- JS (public/assets/js/header-actions.js)
+  - Ajouté adjustDropdownMenu(menu, toggle) : ajoute la classe .dropdown-menu-end si le menu déborde à droite, et limite la hauteur s'il déborde en bas.
+  - Le JS est déjà chargé après bootstrap.bundle.min.js via AssetLoader.
+
+Effet attendu
+- Si le menu risquait de sortir à droite, il sera aligné à droite avec une marge de 5px.
+- Si le menu est trop grand verticalement, il se limitera en hauteur et sera scrollable, donc entièrement visible.
+
+Vérification rapide
+- Le HTML inclut bien le dropdown et le CSS/JS sont chargés dans la page (vérifié via requêtes HTTP).
+
+Souhaitez-vous que je :
+- applique une animation d'apparition pour ce dropdown, ou
+- ajoute un test visuel (headless) pour plusieurs tailles d'écran ?
+
+---
+
+_**User (2025-10-13 07:49Z)**_
+
+Ce projet va être très orienté javascript. Peux tu bien découper les fichiers js pour avoir una arboressence claire et la plus maintenable possible.
+
+---
+
+_**Assistant (copilot/gpt-5-mini)**_
+
+Je vais créer une arborescence JS plus claire, copier les fichiers existants dedans, mettre à jour les inclusions dans les vues, puis supprimer les anciens fichiers. J'ajoute d'abord la todo, puis j'exécute les créations/modifications et enfin je vérifie l'inclusion via HTTP. 
+```
+
+```
+
+```
+
+```
+
+```
+
+```
+Je dois supprimer ou rediriger l'ancien `map-editor.js` pour éviter les doublons et mettre à jour les vues qui l'incluent (`home.php`). Je vais 1) créer un petit wrapper UMD compat pour charger `ui/header.js` et `map-editor.js` en script `<script>` non-modulaire (puisque l'app actuelle n'utilise pas bundler), 2) mettre à jour les includes dans `home.php` et `header.php`. 
+```
+
+```
+Je vais mettre à jour les inclusions : remplacer l'inclusion de `header-actions.js` par `loader.js` dans `header.php`, et dans `home.php` remplacer l'inclusion directe de `map-editor.js` par la nouvelle route `map-editor.js` (pour que le code map utilise la variable `mapData` définie dans la vue). Je vais appliquer ces deux changements et vérifier le rendu HTML. 
+```
+
+```
+
+```
+
+```
+Je vais rendre les fichiers JS non-modulaires : 1) remplacer export/import par des fonctions globales dans `core/dom-ready.js`, 2) transformer `ui/header.js` en IIFE qui utilise `domReady`, 3) remplacer `map-editor.js` par le contenu complet original (IIFE auto-init), 4) retirer l'inclusion directe de `map-editor.js` dans `home.php` (le `loader.js` gère le chargement). Ensuite je vérifierai via HTTP que les scripts sont inclus correctement. 
+```
+
+```
+
+```
+
+```
+Je vais convertir `map-editor.js` en IIFE non-modulaire contenant le code complet (copier contenu complet de public/assets/js/map-editor.js), en m'assurant qu'il utilise window.__MG_domReady et la variable mapData définie par PHP. Ensuite je supprimerai l'ancien `map-editor.js` pour éviter doublons. 
+```
+
+```
+Je vais supprimer le fichier legacy `map-editor.js` pour éviter les doublons, puis exécuter une vérification d'erreurs sur l'ensemble du projet pour m'assurer qu'aucune erreur de syntaxe n'a été introduite. Résultat attendu : fichier supprimé et rapport d'erreurs (si présent).\n ## Actions effectuées
+
+Je vais droit au but : j'ai consolidé l'éditeur en un seul fichier non-modulaire et supprimé le duplicata hérité.
+
+- J'ai transformé `map-editor.js` en un IIFE non-modulaire.
+  - Le script utilise maintenant `window.__MG_domReady` si présent, sinon un fallback DOMContentLoaded.
+  - Il lit `window.mapData` (ou `mapData` si défini globalement par la vue PHP).
+  - Contient une implémentation complète et auto-initialisée de la classe MapEditor (rendu des hex, sélection, zoom, pan, fit-to-screen, événements).
+  - Le code est écrit pour fonctionner sans bundler ni import/export.
+
+- J'ai supprimé le fichier legacy `map-editor.js` (doublon) pour éviter les initialisations multiples.
+
+- Vérification rapide : exécution d'un check d'erreurs sur le projet → No errors found.
+
+## Fichiers modifiés / supprimés
+
+- Modifié : `map-editor.js` — nouveau IIFE complet avec MapEditor.
+- Supprimé : `map-editor.js` — duplicata retiré.
+
+## Ce que vous devez vérifier localement
+
+1. Ouvrir la page d'édition (ex. https://map-generator/) et vérifier que :
+   - L'éditeur se charge et affiche la grille d'hexagones.
+   - Les contrôles Zoom +/-, Fit to Screen fonctionnent.
+   - La sélection d'une tuile affiche les infos dans le panneau `#tileInfo`.
+
+2. Vérifier dans l'inspecteur du navigateur (Console) qu'il n'y a pas d'erreurs JavaScript liées à l'éditeur.
+
+3. Vérifier l'ordre de chargement des scripts dans le HTML :
+   - Assurez-vous que le loader charge d'abord `dom-ready.js` (ou que `window.__MG_domReady` existe), puis `header.js`, puis `map-editor.js`.
+   - Si vous chargez manuellement sans loader, chargez `map-editor.js` après la balise qui définit `mapData` dans la vue.
+
+## Remarques techniques et edge-cases
+
+- Le script suppose que la vue PHP définit `mapData` (ou que `window.mapData` est défini). Si `mapData` est absent, le rendu utilisera une grille par défaut (5x5) — vous pouvez changer ce comportement si vous préférez une erreur explicite.
+- J'ai conservé un style minimal pour les hexagones (clip-path, background) — adaptez selon le CSS existant pour la charte visuelle.
+- Le MapEditor est maintenant IIFE/global pour rester compatible avec l'absence de bundler. Si vous planifiez d'ajouter un bundler (Vite/webpack), on pourra convertir en modules propres.
+
+## Prochaine étape recommandée (si vous voulez que je continue)
+
+- Tester en navigateur et me dire si :
+  - L'éditeur s'initialise correctement (ou me fournir les erreurs console si non).
+- Optionnel mais conseillé :
+  - Ajouter petits tests unitaires (JS) pour la conversion axial→pixel et pour zoom/pan.
+  - Harmoniser les noms/exports des helpers (ex. `__MG_domReady`) et documenter la chaîne de chargement dans un petit README.
+  - Éventuellement remplacer le loader par un vrai bundler si le projet devient plus volumineux.
+
+Souhaitez-vous que je lance une vérification automatique de la page via un headless browser ici (si disponible), ou que j'ajoute un test minimal pour la fonction axialToPixel ?
+
+---
+
+_**User (2025-10-13 07:49Z)**_
+
+Ajouter petits tests unitaires (JS) pour la conversion axial→pixel et pour zoom/pan.
+Harmoniser les noms/exports des helpers (ex. __MG_domReady) et documenter la chaîne de chargement dans un petit README.
+Éventuellement remplacer le loader par un vrai bundler si le projet devient plus volumineux.
+
+---
+
+_**Assistant (copilot/gpt-5-mini)**_
+
+Je vais d'abord créer la todo list détaillée (gestion d'état), puis lire les fichiers JS existants pour préparer les modifications (dom-ready, ui/header.js, editor/map-editor.js). Attendu : todo list ajoutée, contenu des fichiers lus pour patch précis. Je vais lire rapidement les fichiers `dom-ready.js`, `header.js`, et `map-editor.js` pour repérer les endroits à mettre à jour. Cela me permettra de faire des patches précis. 
 
 ---
 

+ 23 - 0
README.md

@@ -0,0 +1,23 @@
+Map Generator — Chargement des scripts et tests
+
+Résumé rapide
+- Le projet charge Bootstrap (bundle) puis un petit loader qui injecte les scripts JS organisés dans `public/assets/js`.
+
+Ordre recommandé de chargement (actuel)
+1. bundle de dépendances (Bootstrap JS) — inclus via CDN ou assets
+2. `/assets/js/core/dom-ready.js` — expose `window.MG.domReady`
+3. `/assets/js/core/geom.js` — expose `window.MG.geom` (axialToPixel, computeZoom, computePanOffset)
+4. `/assets/js/ui/header.js` — interactions de l'en-tête
+5. `/assets/js/editor/map-editor.js` — éditeur de carte (utilise `window.MG`)
+
+Notes
+- Les helpers sont exposés sur `window.MG` pour éviter les problèmes d'absence de bundler.
+- Le loader actuel est un script simple qui insère les fichiers dans l'ordre. Pour un projet JavaScript plus gros, il est recommandé d'utiliser un bundler (Vite, Webpack ou Rollup). Avantages: tree-shaking, modules ES, meilleure gestion des dépendances, sourcemaps et tests plus simples.
+
+Exécution des tests unitaires JS
+1. Installer les dépendances dev: `npm install`
+2. Lancer les tests: `npm test`
+
+Prochaines améliorations possibles
+- Migrer les fichiers JS vers modules ES et ajouter un build step (Vite recommandé pour dev rapide).
+- Ajouter tests pour les interactions DOM (jest + testing-library) et tests e2e si nécessaire.

+ 1 - 1
app/Views/home.php

@@ -181,5 +181,5 @@ const mapData = <?php echo json_encode($map_data); ?>;
 const mapWidth = mapData.width;
 const mapHeight = mapData.height;
 </script>
-<script src="/assets/js/map-editor.js"></script>
+<script src="/assets/js/editor/map-editor.js"></script>
 <?php endif; ?>

+ 1 - 1
app/Views/partials/header.php

@@ -54,4 +54,4 @@
         <!-- Contenu principal de l'application -->
     </div>
 
-    <?php AssetLoader::loadJS(['/assets/bootstrap.bundle.min.js', '/assets/js/header-actions.js']); ?>
+    <?php AssetLoader::loadJS(['/assets/bootstrap.bundle.min.js', '/assets/js/loader.js']); ?>

+ 5 - 0
jest.config.cjs

@@ -0,0 +1,5 @@
+module.exports = {
+  testEnvironment: 'jsdom',
+  roots: ['<rootDir>/tests'],
+  transform: {},
+};

+ 11 - 0
package.json

@@ -0,0 +1,11 @@
+{
+  "name": "map-generator",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "test": "jest --config jest.config.cjs"
+  },
+  "devDependencies": {
+    "jest": "^29.0.0"
+  }
+}

+ 16 - 0
public/assets/js/core/dom-ready.js

@@ -0,0 +1,16 @@
+// Small helper to run a callback on DOMContentLoaded
+function domReady(cb) {
+    if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', cb);
+    } else {
+        cb();
+    }
+}
+
+// Expose global under a structured namespace for compatibilité
+window.MG = window.MG || {};
+window.MG.domReady = domReady;
+
+// Compatibilité ascendante (alias)
+window.__MG_domReady = domReady;
+

+ 26 - 0
public/assets/js/core/geom.js

@@ -0,0 +1,26 @@
+// Fonctions géométriques partagées pour le map editor
+(function(){
+    function axialToPixel(tileSize, q, r) {
+        // pointy-top axial coordinates
+        const x = tileSize * Math.sqrt(3) * (q + r / 2);
+        const y = tileSize * 1.5 * r;
+        return { x, y };
+    }
+
+    function computeZoom(currentZoom, factor) {
+        let z = currentZoom * factor;
+        z = Math.max(0.1, Math.min(5, z));
+        return z;
+    }
+
+    function computePanOffset(offset, dx, dy) {
+        return { x: offset.x + dx, y: offset.y + dy };
+    }
+
+    window.MG = window.MG || {};
+    window.MG.geom = {
+        axialToPixel: axialToPixel,
+        computeZoom: computeZoom,
+        computePanOffset: computePanOffset
+    };
+})();

+ 241 - 0
public/assets/js/editor/map-editor.js

@@ -0,0 +1,241 @@
+(function(){
+    var domReady = (window.MG && window.MG.domReady) || window.__MG_domReady || function(cb){ if (document.readyState==='loading') document.addEventListener('DOMContentLoaded', cb); else cb(); };
+
+    domReady(function(){
+        // On attend que la variable mapData soit définie dans la vue (home.php injecte mapData)
+        if (typeof window.mapData === 'undefined' && typeof mapData !== 'undefined') {
+            window.mapData = mapData;
+        }
+
+        // --- Début du code de MapEditor (copié depuis l'ancien fichier non-modulaire) ---
+        class MapEditor {
+            constructor() {
+                this.canvas = document.getElementById('mapCanvas');
+                this.container = document.querySelector('.map-canvas-container');
+                this.zoomLevel = 1;
+                this.panOffset = { x: 0, y: 0 };
+                this.isDragging = false;
+                this.lastMousePos = { x: 0, y: 0 };
+                this.selectedTile = null;
+                this.tileSize = 50; // Rayon de l'hexagone (du centre au sommet)
+
+                this.init();
+            }
+
+            init() {
+                if (!this.canvas) {
+                    console.error('MapEditor: Élément canvas non trouvé!');
+                    return;
+                }
+
+                if (!this.container) {
+                    console.error('MapEditor: Conteneur non trouvé!');
+                    return;
+                }
+
+                this.setupCanvas();
+                this.renderMap();
+                this.setupEventListeners();
+                this.hideLoading();
+                this.fitToScreen();
+            }
+
+            setupCanvas() {
+                this.canvas.style.transformOrigin = '0 0';
+            }
+
+            axialToPixel(q, r) {
+                if (window.MG && window.MG.geom && typeof window.MG.geom.axialToPixel === 'function') {
+                    return window.MG.geom.axialToPixel(this.tileSize, q, r);
+                }
+                // fallback local implementation
+                const size = this.tileSize;
+                const x = size * Math.sqrt(3) * (q + r / 2);
+                const y = size * 1.5 * r;
+                return { x, y };
+            }
+
+            renderMap() {
+                this.canvas.innerHTML = '';
+
+                const hexWidth = this.tileSize * Math.sqrt(3);
+                const hexHeight = this.tileSize * 2;
+
+                const mapWidth = (window.mapData && window.mapData.width) || 5;
+                const mapHeight = (window.mapData && window.mapData.height) || 5;
+                const radius = Math.floor((Math.min(mapWidth, mapHeight) - 1) / 2);
+                if (radius < 0) return;
+
+                const positions = [];
+                let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+
+                for (let q = -radius; q <= radius; q++) {
+                    const rMin = Math.max(-radius, -q - radius);
+                    const rMax = Math.min(radius, -q + radius);
+                    for (let r = rMin; r <= rMax; r++) {
+                        const { x, y } = this.axialToPixel(q, r);
+                        positions.push({ q, r, x, y });
+                        const halfW = hexWidth / 2;
+                        const halfH = hexHeight / 2;
+                        if (x - halfW < minX) minX = x - halfW;
+                        if (y - halfH < minY) minY = y - halfH;
+                        if (x + halfW > maxX) maxX = x + halfW;
+                        if (y + halfH > maxY) maxY = y + halfH;
+                    }
+                }
+
+                const padding = 20;
+                const canvasWidth = (maxX - minX) + hexWidth + padding;
+                const canvasHeight = (maxY - minY) + hexHeight + padding;
+                this.canvas.style.width = canvasWidth + 'px';
+                this.canvas.style.height = canvasHeight + 'px';
+
+                const offsetX = -minX + padding / 2;
+                const offsetY = -minY + padding / 2;
+                for (const pos of positions) {
+                    this.renderHexagon(pos.q, pos.r, pos.x + offsetX, pos.y + offsetY, hexWidth, hexHeight);
+                }
+            }
+
+            renderHexagon(q, r, x, y, hexWidth, hexHeight) {
+                const hex = document.createElement('div');
+                hex.className = 'hexagon';
+                hex.dataset.q = q;
+                hex.dataset.r = r;
+
+                const expansion = Math.round(hexWidth * 0.004 * 1000) / 1000;
+                const adjWidth = hexWidth + expansion;
+                const adjHeight = hexHeight + expansion;
+
+                const halfW = adjWidth / 2;
+                const halfH = adjHeight / 2;
+
+                hex.style.width = adjWidth + 'px';
+                hex.style.height = adjHeight + 'px';
+                hex.style.left = (Math.round((x - halfW) * 1000) / 1000) + 'px';
+                hex.style.top = (Math.round((y - halfH) * 1000) / 1000) + 'px';
+
+                hex.style.position = 'absolute';
+                hex.style.cursor = 'pointer';
+                hex.style.transition = 'background-color 0.2s';
+                hex.style.backgroundColor = '#ffffff';
+                hex.style.clipPath = 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)';
+
+                hex.addEventListener('click', (e) => {
+                    e.stopPropagation();
+                    this.selectTile(q, r);
+                });
+
+                hex.addEventListener('mouseenter', () => {
+                    if (!this.selectedTile || this.selectedTile.q !== q || this.selectedTile.r !== r) {
+                        hex.style.backgroundColor = '#e3f2fd';
+                    }
+                });
+
+                hex.addEventListener('mouseleave', () => {
+                    if (!this.selectedTile || this.selectedTile.q !== q || this.selectedTile.r !== r) {
+                        hex.style.backgroundColor = '#ffffff';
+                    }
+                });
+
+                this.canvas.appendChild(hex);
+            }
+
+            selectTile(q, r) {
+                if (this.selectedTile) {
+                    const prevHex = this.canvas.querySelector(`[data-q="${this.selectedTile.q}"][data-r="${this.selectedTile.r}"]`);
+                    if (prevHex) {
+                        prevHex.style.backgroundColor = '#ffffff';
+                        prevHex.style.borderColor = '#666';
+                    }
+                }
+
+                this.selectedTile = { q, r };
+                const hex = this.canvas.querySelector(`[data-q="${q}"][data-r="${r}"]`);
+                if (hex) {
+                    hex.style.backgroundColor = '#2196f3';
+                    hex.style.borderColor = '#1976d2';
+                }
+
+                this.showTileInfo(q, r);
+            }
+
+            showTileInfo(q, r) {
+                const tileInfo = document.getElementById('tileInfo');
+                const tilePosition = document.getElementById('tilePosition');
+                const tileType = document.getElementById('tileType');
+                const tileProperties = document.getElementById('tileProperties');
+
+                tilePosition.textContent = `Q: ${q}, R: ${r}`;
+                tileType.textContent = 'Vide';
+
+                tileProperties.innerHTML = `...`;
+                tileInfo.style.display = 'block';
+            }
+
+            setupEventListeners() {
+                document.getElementById('zoomIn').addEventListener('click', (e) => { e.preventDefault(); this.zoom(1.2); });
+                document.getElementById('zoomOut').addEventListener('click', (e) => { e.preventDefault(); this.zoom(0.8); });
+                document.getElementById('fitToScreen').addEventListener('click', (e) => { e.preventDefault(); this.fitToScreen(); });
+
+                this.container.addEventListener('mousedown', (e) => {
+                    if (e.target === this.container) {
+                        this.isDragging = true;
+                        this.lastMousePos = { x: e.clientX, y: e.clientY };
+                        this.canvas.style.cursor = 'grabbing';
+                    }
+                });
+
+                document.addEventListener('mousemove', (e) => {
+                    if (this.isDragging) {
+                        const deltaX = e.clientX - this.lastMousePos.x;
+                        const deltaY = e.clientY - this.lastMousePos.y;
+                        this.panOffset.x += deltaX;
+                        this.panOffset.y += deltaY;
+                        this.updateTransform();
+                        this.lastMousePos = { x: e.clientX, y: e.clientY };
+                    }
+                });
+
+                document.addEventListener('mouseup', () => { this.isDragging = false; this.canvas.style.cursor = 'grab'; });
+
+                this.container.addEventListener('click', () => {
+                    if (this.selectedTile) { this.selectTile(null, null); document.getElementById('tileInfo').style.display = 'none'; }
+                });
+            }
+
+            zoom(factor) {
+                if (window.MG && window.MG.geom && typeof window.MG.geom.computeZoom === 'function') {
+                    this.zoomLevel = window.MG.geom.computeZoom(this.zoomLevel, factor);
+                } else {
+                    this.zoomLevel *= factor;
+                    this.zoomLevel = Math.max(0.1, Math.min(5, this.zoomLevel));
+                }
+                this.updateTransform();
+                const el = document.getElementById('zoomLevel'); if (el) el.textContent = Math.round(this.zoomLevel * 100);
+            }
+
+            fitToScreen() { const containerRect = this.container.getBoundingClientRect(); const canvasRect = this.canvas.getBoundingClientRect(); const scaleX = containerRect.width / canvasRect.width; const scaleY = containerRect.height / canvasRect.height; const scale = Math.min(scaleX, scaleY) * 0.9; this.zoomLevel = scale; this.panOffset = { x: 0, y: 0 }; this.updateTransform(); document.getElementById('zoomLevel').textContent = Math.round(this.zoomLevel * 100); }
+
+            updateTransform() { const tx = Math.round(this.panOffset.x); const ty = Math.round(this.panOffset.y); this.canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${this.zoomLevel})`; }
+
+            hideLoading() { const loading = document.getElementById('mapLoading'); if (loading) { loading.classList.remove('d-flex'); loading.style.display = 'none'; } }
+        }
+
+        // Initialiser l'éditeur
+        (function() {
+            function initMapEditor() {
+                try { new MapEditor(); } catch (error) { console.error('Erreur lors de la création de MapEditor:', error); }
+            }
+
+            if (document.readyState === 'loading') {
+                document.addEventListener('DOMContentLoaded', initMapEditor);
+            } else {
+                initMapEditor();
+            }
+        })();
+
+        // --- Fin du code MapEditor ---
+    });
+})();
+

+ 20 - 0
public/assets/js/loader.js

@@ -0,0 +1,20 @@
+// Petit loader pour charger les modules en environnement sans bundler
+(function() {
+    function loadScript(src, cb) {
+        var s = document.createElement('script');
+        s.src = src;
+        s.onload = cb;
+        s.async = false;
+        document.head.appendChild(s);
+    }
+
+    // Charger d'abord les helpers core, puis UI, puis editor
+    loadScript('/assets/js/core/dom-ready.js', function() {
+        loadScript('/assets/js/ui/header.js', function() {
+            // header initialisé
+        });
+        loadScript('/assets/js/editor/map-editor.js', function() {
+            // map-editor export default n'est pas automatiquement exécuté — le fichier essaie d'auto-instancier
+        });
+    });
+})();

+ 92 - 0
public/assets/js/ui/header.js

@@ -0,0 +1,92 @@
+// Utilise le helper domReady exposé globalement par core/dom-ready.js
+(function() {
+    var domReady = (window.MG && window.MG.domReady) || window.__MG_domReady || function(cb){ if (document.readyState==='loading') document.addEventListener('DOMContentLoaded', cb); else cb(); };
+
+    domReady(function() {
+    const loadBtn = document.getElementById('loadBtn');
+    if (loadBtn) {
+        loadBtn.addEventListener('click', function(e) {
+            e.preventDefault();
+            window.location.href = '/projects';
+        });
+    }
+
+    // Initialiser les dropdowns Bootstrap si disponible
+    try {
+        if (window.bootstrap && window.bootstrap.Dropdown) {
+            const toggles = document.querySelectorAll('[data-bs-toggle="dropdown"]');
+            toggles.forEach(function(toggle) {
+                if (!bootstrap.Dropdown.getInstance(toggle)) {
+                    new bootstrap.Dropdown(toggle);
+                }
+            });
+        }
+    } catch (err) {
+        console.warn('Bootstrap dropdown init failed:', err);
+    }
+
+    // Fallback et ajustements (copié depuis header-actions.js)
+    (function() {
+        const toggles = Array.from(document.querySelectorAll('[data-bs-toggle="dropdown"]'));
+        if (!toggles.length) return;
+
+        function closeAll() {
+            document.querySelectorAll('.dropdown-menu.show').forEach(function(menu) {
+                menu.classList.remove('show');
+            });
+            toggles.forEach(function(t) { t.setAttribute('aria-expanded', 'false'); });
+        }
+
+        toggles.forEach(function(toggle) {
+            toggle.addEventListener('click', function(e) {
+                try {
+                    if (window.bootstrap && window.bootstrap.Dropdown && bootstrap.Dropdown.getInstance(toggle)) {
+                        return;
+                    }
+                } catch (_) {}
+
+                e.preventDefault();
+                e.stopPropagation();
+                const parent = toggle.closest('.dropdown') || toggle.parentElement;
+                const menu = parent ? parent.querySelector('.dropdown-menu') : null;
+                if (!menu) return;
+
+                const isOpen = menu.classList.contains('show');
+                closeAll();
+                if (!isOpen) {
+                    menu.classList.add('show');
+                    toggle.setAttribute('aria-expanded', 'true');
+                    adjustDropdownMenu(menu, toggle);
+                }
+            });
+        });
+
+        document.addEventListener('click', function() {
+            closeAll();
+        });
+    })();
+
+    function adjustDropdownMenu(menu, toggle) {
+        if (!menu) return;
+        menu.style.maxHeight = '';
+        menu.classList.remove('dropdown-menu-end');
+
+        const rect = menu.getBoundingClientRect();
+        const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
+        const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
+
+        if (rect.right > vw - 8) {
+            menu.classList.add('dropdown-menu-end');
+        }
+
+        const rect2 = menu.getBoundingClientRect();
+        if (rect2.bottom > vh - 8) {
+            const available = vh - 16 - rect2.top;
+            if (available > 100) {
+                menu.style.maxHeight = available + 'px';
+                menu.style.overflowY = 'auto';
+            }
+        }
+    }
+    });
+})();

+ 43 - 0
tests/js/geom.test.js

@@ -0,0 +1,43 @@
+const { JSDOM } = require('jsdom');
+
+describe('MG.geom utilities', () => {
+  beforeAll(() => {
+    // préparer un window global minimal
+    const dom = new JSDOM(`<!doctype html><html><body></body></html>`);
+    global.window = dom.window;
+    global.document = dom.window.document;
+    global.window.MG = global.window.MG || {};
+    // injecter l'implémentation à tester depuis le fichier source
+    const geomSrc = require('fs').readFileSync(require('path').resolve(__dirname, '../../public/assets/js/core/geom.js'), 'utf8');
+    // exécuter le code pour qu'il attache window.MG.geom
+    const fn = new Function('window', 'document', geomSrc + '\n return window.MG.geom;');
+    const geom = fn(global.window, global.document);
+    global.window.MG.geom = geom;
+  });
+
+  test('axialToPixel returns expected values for simple coords', () => {
+    const size = 10;
+    const p00 = global.window.MG.geom.axialToPixel(size, 0, 0);
+    expect(p00.x).toBeCloseTo(0);
+    expect(p00.y).toBeCloseTo(0);
+
+    const p10 = global.window.MG.geom.axialToPixel(size, 1, 0);
+    expect(p10.x).toBeGreaterThan(0);
+    expect(p10.y).toBeCloseTo(0);
+
+    const p01 = global.window.MG.geom.axialToPixel(size, 0, 1);
+    expect(p01.y).toBeGreaterThan(0);
+  });
+
+  test('computeZoom clamps values', () => {
+    expect(global.window.MG.geom.computeZoom(1, 10)).toBeLessThanOrEqual(5);
+    expect(global.window.MG.geom.computeZoom(0.01, 0.01)).toBeGreaterThanOrEqual(0.1);
+  });
+
+  test('computePanOffset adds deltas', () => {
+    const off = { x: 5, y: 7 };
+    const r = global.window.MG.geom.computePanOffset(off, 3, -2);
+    expect(r.x).toBe(8);
+    expect(r.y).toBe(5);
+  });
+});