feat: add initial view button and no-results zone alert for mobile assets map

This commit is contained in:
kdeterme 2026-09-13 12:13:41 +02:00
parent 27ab3a8c1b
commit 4b1bc59f1e
2 changed files with 233 additions and 41 deletions

View file

@ -165,6 +165,46 @@
transform: scale(0.92); 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 */ /* Marqueur utilisateur GPS */
.user-gps-marker { .user-gps-marker {
width: 18px; width: 18px;
@ -374,6 +414,19 @@
</div> </div>
</div> </div>
<!-- Alerte Aucun résultat dans la zone visible (invitation retour vue initiale) -->
<div id="noResultsInZoneAlert" class="alert alert-info py-2 px-2.5 mb-1.5 small d-flex align-items-center gap-2 rounded-3 shadow-sm d-none flex-shrink-0" role="alert">
<i class="bi bi-info-circle-fill fs-5 text-primary flex-shrink-0"></i>
<div class="flex-grow-1" style="line-height: 1.3;">
<div class="fw-semibold text-dark">{% translate "Aucun résultat dans cette zone" %}</div>
<div class="text-secondary small">{% translate "Appuyez sur le bouton pour revenir à la vue initiale." %}</div>
</div>
<button type="button" id="btnResetToInitialView" class="btn btn-sm btn-primary rounded-3 text-nowrap py-1.5 px-2.5 d-flex align-items-center gap-1 shadow-sm flex-shrink-0">
<i class="bi bi-arrows-angle-expand"></i>
<span>{% translate "Vue initiale" %}</span>
</button>
</div>
<!-- Indicateur de résultats & compteur --> <!-- Indicateur de résultats & compteur -->
<div class="d-flex justify-content-between align-items-center mb-1.5 px-1 text-secondary small flex-shrink-0"> <div class="d-flex justify-content-between align-items-center mb-1.5 px-1 text-secondary small flex-shrink-0">
<div class="d-flex align-items-center gap-1 flex-wrap"> <div class="d-flex align-items-center gap-1 flex-wrap">
@ -407,6 +460,11 @@
<p class="small text-muted mb-0">{% translate "Veuillez choisir une thématique ci-dessus pour afficher les assets sur la carte." %}</p> <p class="small text-muted mb-0">{% translate "Veuillez choisir une thématique ci-dessus pour afficher les assets sur la carte." %}</p>
</div> </div>
<!-- Bouton flottant Vue initiale -->
<button type="button" id="btnInitialView" class="map-initial-view-btn" title="{% translate 'Retour à la vue initiale (toute la commune)' %}" aria-label="{% translate 'Retour à la vue initiale' %}">
<i class="bi bi-arrows-angle-expand"></i>
</button>
<!-- Bouton flottant GPS --> <!-- Bouton flottant GPS -->
<button type="button" id="btnGpsLocate" class="map-gps-btn" title="{% translate 'Ma position' %}"> <button type="button" id="btnGpsLocate" class="map-gps-btn" title="{% translate 'Ma position' %}">
<i class="bi bi-crosshair"></i> <i class="bi bi-crosshair"></i>
@ -433,10 +491,15 @@
<i class="bi bi-search fs-1"></i> <i class="bi bi-search fs-1"></i>
</div> </div>
<h6 class="fw-bold text-secondary">{% translate "Aucun asset trouvé" %}</h6> <h6 class="fw-bold text-secondary">{% translate "Aucun asset trouvé" %}</h6>
<p class="small text-muted mb-3">{% translate "Modifiez votre recherche ou zoomez sur une autre zone." %}</p> <p class="small text-muted mb-3" id="emptyListSubtitle">{% translate "Modifiez votre recherche ou zoomez sur une autre zone." %}</p>
<button type="button" class="btn btn-outline-primary btn-sm rounded-3" id="btnResetFilters"> <div class="d-flex justify-content-center gap-2 flex-wrap">
{% translate "Réinitialiser les filtres" %} <button type="button" class="btn btn-outline-primary btn-sm rounded-3" id="btnResetFilters">
</button> {% translate "Réinitialiser les filtres" %}
</button>
<button type="button" class="btn btn-primary btn-sm rounded-3 d-none" id="btnListInitialView">
<i class="bi bi-arrows-angle-expand me-1"></i>{% translate "Vue initiale" %}
</button>
</div>
</div> </div>
</div> </div>
@ -520,7 +583,7 @@
let isProgrammaticMove = false; let isProgrammaticMove = false;
let startCenter = null; let startCenter = null;
let startZoom = 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; const defaultZoom = window.MAP_DEFAULT_ZOOM || 12;
let userCoordinates = null; // { lat, lon } let userCoordinates = null; // { lat, lon }
@ -528,6 +591,7 @@
let userMarker = null; let userMarker = null;
let activeMarkers = []; let activeMarkers = [];
let searchTimeout = null; let searchTimeout = null;
let noResultsAlertTimeout = null;
let mapMoveTimeout = null; let mapMoveTimeout = null;
let lastResults = []; let lastResults = [];
@ -547,6 +611,11 @@
const noThematicSelectedMessage = document.getElementById('noThematicSelectedMessage'); const noThematicSelectedMessage = document.getElementById('noThematicSelectedMessage');
const mapThematicPrompt = document.getElementById('mapThematicPrompt'); const mapThematicPrompt = document.getElementById('mapThematicPrompt');
const filterLimitAlert = document.getElementById('filterLimitAlert'); 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 limitNotice = document.getElementById('limitNotice');
const zoneIndicator = document.getElementById('zoneIndicator'); const zoneIndicator = document.getElementById('zoneIndicator');
const resultsCountEl = document.getElementById('resultsCount'); const resultsCountEl = document.getElementById('resultsCount');
@ -701,6 +770,10 @@
closeBottomSheet(); closeBottomSheet();
}); });
map.on('movestart', () => {
hideNoResultsInZoneAlert();
});
// Détection du déplacement ou zoom pour filtrage par zone visible (bbox) et synchronisation de la liste // Détection du déplacement ou zoom pour filtrage par zone visible (bbox) et synchronisation de la liste
map.on('moveend', () => { map.on('moveend', () => {
// Ignorer si la vue active n'est pas la carte ou si aucune thématique n'est choisie // 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 // 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 // L'utilisateur doit choisir au minimum une thématique
if (!currentThematic) { if (!currentThematic) {
loadingSpinner.classList.add('d-none'); loadingSpinner.classList.add('d-none');
if (filterLimitAlert) filterLimitAlert.classList.add('d-none'); if (filterLimitAlert) filterLimitAlert.classList.add('d-none');
if (limitNotice) limitNotice.classList.add('d-none'); if (limitNotice) limitNotice.classList.add('d-none');
hideNoResultsInZoneAlert();
resultsCountEl.textContent = '0'; resultsCountEl.textContent = '0';
if (zoneIndicator) zoneIndicator.classList.add('d-none'); if (zoneIndicator) zoneIndicator.classList.add('d-none');
if (mapThematicPrompt) mapThematicPrompt.classList.remove('d-none'); if (mapThematicPrompt) mapThematicPrompt.classList.remove('d-none');
@ -819,7 +893,7 @@
} }
} }
} }
if (!bboxToSend && currentView === 'map') { if (!bboxToSend && currentView === 'map' && isBboxFiltered) {
bboxToSend = getValidMapBbox(); bboxToSend = getValidMapBbox();
if (bboxToSend) { if (bboxToSend) {
currentBbox = bboxToSend; currentBbox = bboxToSend;
@ -845,7 +919,7 @@
}) })
.then(data => { .then(data => {
loadingSpinner.classList.add('d-none'); loadingSpinner.classList.add('d-none');
renderResults(data, fromMapMove); renderResults(data, fromMapMove, options);
}) })
.catch(err => { .catch(err => {
console.error("Erreur chargement assets:", err); console.error("Erreur chargement assets:", err);
@ -854,7 +928,7 @@
} }
// 4. Rendu des résultats sur Carte et dans la Liste // 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 || []; lastResults = data.results || [];
// Gestion de la limite de 500 assets atteinte // 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) // Synchronisation de la Liste (toujours à jour avec la zone zoomée ou les filtres)
renderListView(lastResults); renderListView(lastResults);
// Rendu Carte (mise à jour des marqueurs) // Rendu Carte (mise à jour des marqueurs, sans modifier la visualisation)
renderMapView(lastResults, fromMapMove); renderMapView(lastResults, fromMapMove);
// Gestion du message invitant à revenir à la vue initiale en cas de recherche sans résultat
handleNoResultsInZoneAlert(data, options);
} }
function renderListView(results) { function renderListView(results) {
assetsListContainer.innerHTML = ''; assetsListContainer.innerHTML = '';
if (!results || results.length === 0) { if (!results || results.length === 0) {
emptyListMessage.classList.remove('d-none'); 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; return;
} }
emptyListMessage.classList.add('d-none'); emptyListMessage.classList.add('d-none');
@ -921,9 +1011,6 @@
activeMarkers.forEach(m => m.remove()); activeMarkers.forEach(m => m.remove());
activeMarkers = []; activeMarkers = [];
const bounds = new maplibregl.LngLatBounds();
let hasValidCoords = false;
results.forEach(asset => { results.forEach(asset => {
if (asset.lon === null || asset.lat === null) return; if (asset.lon === null || asset.lat === null) return;
@ -944,15 +1031,9 @@
.addTo(map); .addTo(map);
activeMarkers.push(marker); 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 // Ne jamais modifier la visualisation (zoom ou pan) de manière automatique lors d'une action de filtre ou de recherche
if (hasValidCoords && !userCoordinates && !fromMapMove && !hasSavedPosition && !isBboxFiltered) {
isProgrammaticMove = true;
map.fitBounds(bounds, { padding: 40, maxZoom: 16 });
}
} }
// 5. Volet d'aperçu inférieur (Bottom Sheet) // 5. Volet d'aperçu inférieur (Bottom Sheet)
@ -1076,6 +1157,7 @@
zoneIndicator.classList.add('d-none'); zoneIndicator.classList.add('d-none');
} }
hideNoResultsInZoneAlert();
closeBottomSheet(); closeBottomSheet();
// Supprimer la persistance localStorage // Supprimer la persistance localStorage
@ -1107,39 +1189,141 @@
fetchAndRenderAssets(false); 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) { if (btnGlobalReset) {
btnGlobalReset.addEventListener('click', resetAll); btnGlobalReset.addEventListener('click', resetAll);
} }
if (btnResetFilters) { if (btnResetFilters) {
btnResetFilters.addEventListener('click', resetAll); 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 // Clic sur l'indicateur de zone : élargir à toute la commune sans réinitialiser la recherche
if (zoneIndicator) { if (zoneIndicator) {
zoneIndicator.addEventListener('click', () => { zoneIndicator.addEventListener('click', returnToInitialView);
isBboxFiltered = false;
currentBbox = null;
zoneIndicator.classList.add('d-none');
hasSavedPosition = false;
saveState();
fetchAndRenderAssets(false);
});
} }
// 8. Filtres et événements // 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 => { thematicChips.forEach(chip => {
chip.addEventListener('click', () => { chip.addEventListener('click', () => {
thematicChips.forEach(c => c.classList.remove('active')); thematicChips.forEach(c => c.classList.remove('active'));
chip.classList.add('active'); chip.classList.add('active');
currentThematic = chip.dataset.thematic || ''; currentThematic = chip.dataset.thematic || '';
closeBottomSheet(); closeBottomSheet();
isBboxFiltered = false; hideNoResultsInZoneAlert();
currentBbox = null;
hasSavedPosition = false;
if (zoneIndicator) zoneIndicator.classList.add('d-none');
saveState(); 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', () => { statusSelect.addEventListener('change', () => {
currentStatus = statusSelect.value; currentStatus = statusSelect.value;
closeBottomSheet(); closeBottomSheet();
hideNoResultsInZoneAlert();
saveState(); saveState();
fetchAndRenderAssets(false); fetchAndRenderAssets(isBboxFiltered);
}); });
// Champ de recherche // Champ de recherche : ne pas modifier la visualisation (zoom ou pan)
function handleSearchInput() { function handleSearchInput() {
currentQuery = searchInput.value.trim(); currentQuery = searchInput.value.trim();
if (currentQuery) { if (currentQuery) {
@ -1159,11 +1344,12 @@
} else { } else {
clearSearchBtn.classList.add('d-none'); clearSearchBtn.classList.add('d-none');
} }
hideNoResultsInZoneAlert();
saveState(); saveState();
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
closeBottomSheet(); closeBottomSheet();
fetchAndRenderAssets(false); fetchAndRenderAssets(isBboxFiltered, { fromSearchTyping: true });
}, 300); }, 300);
} }
@ -1174,7 +1360,7 @@
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
closeBottomSheet(); closeBottomSheet();
saveState(); saveState();
fetchAndRenderAssets(false); fetchAndRenderAssets(isBboxFiltered, { isExplicitSubmit: true });
} }
}); });
@ -1182,16 +1368,17 @@
searchInput.value = ''; searchInput.value = '';
currentQuery = ''; currentQuery = '';
clearSearchBtn.classList.add('d-none'); clearSearchBtn.classList.add('d-none');
hideNoResultsInZoneAlert();
closeBottomSheet(); closeBottomSheet();
saveState(); saveState();
fetchAndRenderAssets(false); fetchAndRenderAssets(isBboxFiltered);
}); });
submitSearchBtn.addEventListener('click', () => { submitSearchBtn.addEventListener('click', () => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
closeBottomSheet(); closeBottomSheet();
saveState(); saveState();
fetchAndRenderAssets(false); fetchAndRenderAssets(isBboxFiltered, { isExplicitSubmit: true });
}); });
// Boutons Vue Carte / Liste // Boutons Vue Carte / Liste

View file

@ -274,9 +274,14 @@ class MobileAssetDetailTests(TestCase):
self.assertIn("accessible_thematics", response.context) self.assertIn("accessible_thematics", response.context)
self.assertIn("thematic_config_json", response.context) self.assertIn("thematic_config_json", response.context)
self.assertIn("asset_status_choices", 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, "initMap")
self.assertContains(response, "maplibre-gl.js") 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): def test_mobile_assets_search_api(self):
"""Test mobile_assets_search JSON endpoint with filters and search queries.""" """Test mobile_assets_search JSON endpoint with filters and search queries."""