feat: add initial view button and no-results zone alert for mobile assets map
This commit is contained in:
parent
27ab3a8c1b
commit
4b1bc59f1e
2 changed files with 233 additions and 41 deletions
|
|
@ -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 @@
|
|||
</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 -->
|
||||
<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">
|
||||
|
|
@ -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>
|
||||
</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 -->
|
||||
<button type="button" id="btnGpsLocate" class="map-gps-btn" title="{% translate 'Ma position' %}">
|
||||
<i class="bi bi-crosshair"></i>
|
||||
|
|
@ -433,10 +491,15 @@
|
|||
<i class="bi bi-search fs-1"></i>
|
||||
</div>
|
||||
<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>
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm rounded-3" id="btnResetFilters">
|
||||
{% 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>
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue