From 4b1bc59f1e0dd8e11eddbf8b5070195feaa6ad55 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sun, 13 Sep 2026 12:13:41 +0200 Subject: [PATCH] feat: add initial view button and no-results zone alert for mobile assets map --- .../mobile/assets/assets_index_mobile.html | 267 +++++++++++++++--- loko/mobile/tests_asset_mobile.py | 7 +- 2 files changed, 233 insertions(+), 41 deletions(-) diff --git a/loko/mobile/templates/mobile/assets/assets_index_mobile.html b/loko/mobile/templates/mobile/assets/assets_index_mobile.html index 3d0f8a1..7aa83df 100644 --- a/loko/mobile/templates/mobile/assets/assets_index_mobile.html +++ b/loko/mobile/templates/mobile/assets/assets_index_mobile.html @@ -165,6 +165,46 @@ transform: scale(0.92); } + /* Bouton flottant Vue Initiale */ + .map-initial-view-btn { + position: absolute; + bottom: 4.5rem; + right: 1.25rem; + z-index: 10; + width: 44px; + height: 44px; + border-radius: 50%; + background: #ffffff; + border: 1px solid #cbd5e1; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + display: flex; + align-items: center; + justify-content: center; + color: var(--brand-color, #2f3e8b); + font-size: 1.15rem; + cursor: pointer; + transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.2s ease; + } + .map-initial-view-btn:active { + transform: scale(0.92); + } + .map-initial-view-btn.highlight-pulse { + animation: initial-btn-pulse 1.5s infinite; + border-color: #2563eb; + color: #2563eb; + } + @keyframes initial-btn-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.7); + } + 70% { + box-shadow: 0 0 0 12px rgba(37, 99, 235, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); + } + } + /* Marqueur utilisateur GPS */ .user-gps-marker { width: 18px; @@ -374,6 +414,19 @@ + + +
@@ -407,6 +460,11 @@

{% translate "Veuillez choisir une thématique ci-dessus pour afficher les assets sur la carte." %}

+ + +
{% translate "Aucun asset trouvé" %}
-

{% translate "Modifiez votre recherche ou zoomez sur une autre zone." %}

- +

{% translate "Modifiez votre recherche ou zoomez sur une autre zone." %}

+
+ + +
@@ -520,7 +583,7 @@ let isProgrammaticMove = false; let startCenter = null; let startZoom = null; - const defaultCenter = window.MAP_DEFAULT_CENTER || [4.3517, 50.8503]; // Bruxelles par défaut + const defaultCenter = window.MAP_DEFAULT_CENTER || [4.3572, 50.8477]; // Bruxelles par défaut const defaultZoom = window.MAP_DEFAULT_ZOOM || 12; let userCoordinates = null; // { lat, lon } @@ -528,6 +591,7 @@ let userMarker = null; let activeMarkers = []; let searchTimeout = null; + let noResultsAlertTimeout = null; let mapMoveTimeout = null; let lastResults = []; @@ -547,6 +611,11 @@ const noThematicSelectedMessage = document.getElementById('noThematicSelectedMessage'); const mapThematicPrompt = document.getElementById('mapThematicPrompt'); const filterLimitAlert = document.getElementById('filterLimitAlert'); + const noResultsInZoneAlert = document.getElementById('noResultsInZoneAlert'); + const btnResetToInitialView = document.getElementById('btnResetToInitialView'); + const btnInitialView = document.getElementById('btnInitialView'); + const btnListInitialView = document.getElementById('btnListInitialView'); + const emptyListSubtitle = document.getElementById('emptyListSubtitle'); const limitNotice = document.getElementById('limitNotice'); const zoneIndicator = document.getElementById('zoneIndicator'); const resultsCountEl = document.getElementById('resultsCount'); @@ -701,6 +770,10 @@ closeBottomSheet(); }); + map.on('movestart', () => { + hideNoResultsInZoneAlert(); + }); + // Détection du déplacement ou zoom pour filtrage par zone visible (bbox) et synchronisation de la liste map.on('moveend', () => { // Ignorer si la vue active n'est pas la carte ou si aucune thématique n'est choisie @@ -777,12 +850,13 @@ } // 3. Récupération des données depuis le backend - function fetchAndRenderAssets(fromMapMove = false) { + function fetchAndRenderAssets(fromMapMove = false, options = {}) { // L'utilisateur doit choisir au minimum une thématique if (!currentThematic) { loadingSpinner.classList.add('d-none'); if (filterLimitAlert) filterLimitAlert.classList.add('d-none'); if (limitNotice) limitNotice.classList.add('d-none'); + hideNoResultsInZoneAlert(); resultsCountEl.textContent = '0'; if (zoneIndicator) zoneIndicator.classList.add('d-none'); if (mapThematicPrompt) mapThematicPrompt.classList.remove('d-none'); @@ -819,7 +893,7 @@ } } } - if (!bboxToSend && currentView === 'map') { + if (!bboxToSend && currentView === 'map' && isBboxFiltered) { bboxToSend = getValidMapBbox(); if (bboxToSend) { currentBbox = bboxToSend; @@ -845,7 +919,7 @@ }) .then(data => { loadingSpinner.classList.add('d-none'); - renderResults(data, fromMapMove); + renderResults(data, fromMapMove, options); }) .catch(err => { console.error("Erreur chargement assets:", err); @@ -854,7 +928,7 @@ } // 4. Rendu des résultats sur Carte et dans la Liste - function renderResults(data, fromMapMove = false) { + function renderResults(data, fromMapMove = false, options = {}) { lastResults = data.results || []; // Gestion de la limite de 500 assets atteinte @@ -871,14 +945,30 @@ // Synchronisation de la Liste (toujours à jour avec la zone zoomée ou les filtres) renderListView(lastResults); - // Rendu Carte (mise à jour des marqueurs) + // Rendu Carte (mise à jour des marqueurs, sans modifier la visualisation) renderMapView(lastResults, fromMapMove); + + // Gestion du message invitant à revenir à la vue initiale en cas de recherche sans résultat + handleNoResultsInZoneAlert(data, options); } function renderListView(results) { assetsListContainer.innerHTML = ''; if (!results || results.length === 0) { emptyListMessage.classList.remove('d-none'); + if (btnListInitialView) { + if (currentQuery && !isAtMaxBbox()) { + btnListInitialView.classList.remove('d-none'); + if (emptyListSubtitle) { + emptyListSubtitle.textContent = "{% translate 'Aucun asset trouvé dans cette zone pour votre recherche.' %}"; + } + } else { + btnListInitialView.classList.add('d-none'); + if (emptyListSubtitle) { + emptyListSubtitle.textContent = "{% translate 'Modifiez votre recherche ou zoomez sur une autre zone.' %}"; + } + } + } return; } emptyListMessage.classList.add('d-none'); @@ -921,9 +1011,6 @@ activeMarkers.forEach(m => m.remove()); activeMarkers = []; - const bounds = new maplibregl.LngLatBounds(); - let hasValidCoords = false; - results.forEach(asset => { if (asset.lon === null || asset.lat === null) return; @@ -944,15 +1031,9 @@ .addTo(map); activeMarkers.push(marker); - bounds.extend([asset.lon, asset.lat]); - hasValidCoords = true; }); - // Ajuster la vue uniquement s'il n'y a pas de position sauvegardée ni filtrage spatial actif - if (hasValidCoords && !userCoordinates && !fromMapMove && !hasSavedPosition && !isBboxFiltered) { - isProgrammaticMove = true; - map.fitBounds(bounds, { padding: 40, maxZoom: 16 }); - } + // Ne jamais modifier la visualisation (zoom ou pan) de manière automatique lors d'une action de filtre ou de recherche } // 5. Volet d'aperçu inférieur (Bottom Sheet) @@ -1076,6 +1157,7 @@ zoneIndicator.classList.add('d-none'); } + hideNoResultsInZoneAlert(); closeBottomSheet(); // Supprimer la persistance localStorage @@ -1107,39 +1189,141 @@ fetchAndRenderAssets(false); } + // Vérifier si la vue courante correspond à la boîte englobante maximale (vue initiale) + function isAtMaxBbox() { + if (!map) return true; + const currentZoom = map.getZoom(); + const currentCenter = map.getCenter(); + + const dLng = Math.abs(currentCenter.lng - defaultCenter[0]); + const dLat = Math.abs(currentCenter.lat - defaultCenter[1]); + const zoomDiff = currentZoom - defaultZoom; + + // Si le zoom est plus grand que le zoom initial (+0.5) ou si le centre s'est décalé (> 0.02 deg ~ 1.5 km) + if (zoomDiff > 0.5 || dLng > 0.02 || dLat > 0.02) { + return false; + } + + // Si isBboxFiltered est actif et que la position/zoom a bougé + if (isBboxFiltered && (zoomDiff > 0.25 || dLng > 0.015 || dLat > 0.015)) { + return false; + } + + return true; + } + + function showNoResultsInZoneAlert() { + if (noResultsInZoneAlert) { + noResultsInZoneAlert.classList.remove('d-none'); + } + if (btnInitialView) { + btnInitialView.classList.add('highlight-pulse'); + } + if (btnListInitialView) { + btnListInitialView.classList.remove('d-none'); + } + } + + function hideNoResultsInZoneAlert() { + clearTimeout(noResultsAlertTimeout); + if (noResultsInZoneAlert) { + noResultsInZoneAlert.classList.add('d-none'); + } + if (btnInitialView) { + btnInitialView.classList.remove('highlight-pulse'); + } + } + + function handleNoResultsInZoneAlert(data, options = {}) { + clearTimeout(noResultsAlertTimeout); + + const count = data.count || (data.results ? data.results.length : 0); + const hasQuery = Boolean(currentQuery && currentQuery.trim().length > 0); + + // Si la recherche a retourné des résultats ou si aucune recherche textuelle n'est active, masquer l'alerte + if (!hasQuery || count > 0) { + hideNoResultsInZoneAlert(); + return; + } + + // Si la vue correspond déjà à la bbox maximale, ne pas afficher l'alerte (recherche déjà globale) + if (isAtMaxBbox()) { + hideNoResultsInZoneAlert(); + return; + } + + // Recherche sans résultat ET vue restreinte à une zone inférieure à la bbox maximale : + if (options.fromSearchTyping) { + // Petite latence pour lui permettre d'entrer suffisamment de caractères pour sa recherche + if (currentQuery.trim().length < 2) { + hideNoResultsInZoneAlert(); + return; + } + noResultsAlertTimeout = setTimeout(() => { + if (currentQuery && currentQuery.trim().length >= 2 && lastResults.length === 0 && !isAtMaxBbox()) { + showNoResultsInZoneAlert(); + } + }, 850); + } else { + // Soumission explicite (touche Entrée ou clic loupe) + showNoResultsInZoneAlert(); + } + } + + // Retour à la vue initiale (toute la commune) tout en conservant la recherche et la thématique + function returnToInitialView() { + isBboxFiltered = false; + currentBbox = null; + hasSavedPosition = false; + if (zoneIndicator) zoneIndicator.classList.add('d-none'); + hideNoResultsInZoneAlert(); + + if (map) { + isProgrammaticMove = true; + map.flyTo({ + center: defaultCenter, + zoom: defaultZoom, + essential: true + }); + } + + saveState(); + fetchAndRenderAssets(false); + } + if (btnGlobalReset) { btnGlobalReset.addEventListener('click', resetAll); } if (btnResetFilters) { btnResetFilters.addEventListener('click', resetAll); } + if (btnResetToInitialView) { + btnResetToInitialView.addEventListener('click', returnToInitialView); + } + if (btnInitialView) { + btnInitialView.addEventListener('click', returnToInitialView); + } + if (btnListInitialView) { + btnListInitialView.addEventListener('click', returnToInitialView); + } // Clic sur l'indicateur de zone : élargir à toute la commune sans réinitialiser la recherche if (zoneIndicator) { - zoneIndicator.addEventListener('click', () => { - isBboxFiltered = false; - currentBbox = null; - zoneIndicator.classList.add('d-none'); - hasSavedPosition = false; - saveState(); - fetchAndRenderAssets(false); - }); + zoneIndicator.addEventListener('click', returnToInitialView); } // 8. Filtres et événements - // Chips thématiques (au moins une thématique obligatoire) + // Chips thématiques (au moins une thématique obligatoire) : ne pas modifier la visualisation (zoom ou pan) thematicChips.forEach(chip => { chip.addEventListener('click', () => { thematicChips.forEach(c => c.classList.remove('active')); chip.classList.add('active'); currentThematic = chip.dataset.thematic || ''; closeBottomSheet(); - isBboxFiltered = false; - currentBbox = null; - hasSavedPosition = false; - if (zoneIndicator) zoneIndicator.classList.add('d-none'); + hideNoResultsInZoneAlert(); saveState(); - fetchAndRenderAssets(false); + // Conserver la boîte englobante si un filtrage spatial est actif, sans modifier le zoom ou pan + fetchAndRenderAssets(isBboxFiltered); }); }); @@ -1147,11 +1331,12 @@ statusSelect.addEventListener('change', () => { currentStatus = statusSelect.value; closeBottomSheet(); + hideNoResultsInZoneAlert(); saveState(); - fetchAndRenderAssets(false); + fetchAndRenderAssets(isBboxFiltered); }); - // Champ de recherche + // Champ de recherche : ne pas modifier la visualisation (zoom ou pan) function handleSearchInput() { currentQuery = searchInput.value.trim(); if (currentQuery) { @@ -1159,11 +1344,12 @@ } else { clearSearchBtn.classList.add('d-none'); } + hideNoResultsInZoneAlert(); saveState(); clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { closeBottomSheet(); - fetchAndRenderAssets(false); + fetchAndRenderAssets(isBboxFiltered, { fromSearchTyping: true }); }, 300); } @@ -1174,7 +1360,7 @@ clearTimeout(searchTimeout); closeBottomSheet(); saveState(); - fetchAndRenderAssets(false); + fetchAndRenderAssets(isBboxFiltered, { isExplicitSubmit: true }); } }); @@ -1182,16 +1368,17 @@ searchInput.value = ''; currentQuery = ''; clearSearchBtn.classList.add('d-none'); + hideNoResultsInZoneAlert(); closeBottomSheet(); saveState(); - fetchAndRenderAssets(false); + fetchAndRenderAssets(isBboxFiltered); }); submitSearchBtn.addEventListener('click', () => { clearTimeout(searchTimeout); closeBottomSheet(); saveState(); - fetchAndRenderAssets(false); + fetchAndRenderAssets(isBboxFiltered, { isExplicitSubmit: true }); }); // Boutons Vue Carte / Liste diff --git a/loko/mobile/tests_asset_mobile.py b/loko/mobile/tests_asset_mobile.py index 666ed16..9546cb0 100644 --- a/loko/mobile/tests_asset_mobile.py +++ b/loko/mobile/tests_asset_mobile.py @@ -274,9 +274,14 @@ class MobileAssetDetailTests(TestCase): self.assertIn("accessible_thematics", response.context) self.assertIn("thematic_config_json", response.context) self.assertIn("asset_status_choices", response.context) - # Assert JS script block is rendered and loaded + # Assert JS script block and initial view controls are rendered self.assertContains(response, "initMap") self.assertContains(response, "maplibre-gl.js") + self.assertContains(response, "noResultsInZoneAlert") + self.assertContains(response, "btnResetToInitialView") + self.assertContains(response, "btnInitialView") + self.assertContains(response, "returnToInitialView") + self.assertContains(response, "handleNoResultsInZoneAlert") def test_mobile_assets_search_api(self): """Test mobile_assets_search JSON endpoint with filters and search queries."""