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 @@
+
+
+
+
+
{% translate "Aucun résultat dans cette zone" %}
+
{% translate "Appuyez sur le bouton pour revenir à la vue initiale." %}
+
+
+
+
@@ -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."""