feat: enhance mobile asset map UI with floating alerts and updated limit handling
This commit is contained in:
parent
4b1bc59f1e
commit
2ada4f88b7
3 changed files with 204 additions and 134 deletions
|
|
@ -141,6 +141,30 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* Alerte flottante sur la carte */
|
||||
.map-floating-alert {
|
||||
position: absolute;
|
||||
top: 0.65rem;
|
||||
left: 0.65rem;
|
||||
right: 0.65rem;
|
||||
z-index: 25;
|
||||
background-color: rgba(240, 249, 255, 0.96) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid #bae6fd !important;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18) !important;
|
||||
animation: slideDownFadeIn 0.25s ease-out;
|
||||
}
|
||||
@keyframes slideDownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bouton flottant GPS */
|
||||
.map-gps-btn {
|
||||
position: absolute;
|
||||
|
|
@ -410,23 +434,10 @@
|
|||
<div id="filterLimitAlert" class="alert alert-warning py-1.5 px-2.5 mb-1.5 small d-flex align-items-center gap-2 rounded-3 d-none flex-shrink-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-6 text-warning flex-shrink-0"></i>
|
||||
<div class="flex-grow-1" style="line-height: 1.25;">
|
||||
<strong>{% translate "Limite atteinte" %}</strong> : {% translate "Plus de 500 assets correspondent. Veuillez zoomer sur la carte ou affiner vos critères de recherche." %}
|
||||
<strong>{% translate "Limite atteinte" %}</strong> : <span id="filterLimitAlertText">{% translate "Plus de 500 assets correspondent. Veuillez zoomer sur la carte ou affiner vos critères de recherche pour les afficher." %}</span>
|
||||
</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">
|
||||
|
|
@ -451,6 +462,20 @@
|
|||
<div id="assets-map-wrapper">
|
||||
<div id="assets-map"></div>
|
||||
|
||||
<!-- Alerte flottante : Aucun résultat dans la zone visible (invitation retour vue initiale) -->
|
||||
<div id="noResultsInZoneAlert" class="map-floating-alert alert alert-info py-2 px-2.5 mb-0 small d-flex align-items-center gap-2 rounded-3 shadow d-none" 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.25; min-width: 0;">
|
||||
<div class="fw-semibold text-dark text-truncate">{% 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 px-2.5 d-flex align-items-center gap-1 shadow-sm flex-shrink-0">
|
||||
<i class="bi bi-arrows-angle-expand"></i>
|
||||
<span class="small">{% translate "Vue initiale" %}</span>
|
||||
</button>
|
||||
<button type="button" id="btnCloseNoResultsAlert" class="btn-close ms-0.5 flex-shrink-0" style="font-size: 0.7rem;" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
|
||||
<!-- Invite sélection thématique sur la carte -->
|
||||
<div id="mapThematicPrompt" class="position-absolute top-50 start-50 translate-middle text-center p-3 bg-white bg-opacity-95 rounded-4 shadow-sm border {% if selected_thematic %}d-none{% endif %}" style="z-index: 8; max-width: 85%; pointer-events: none;">
|
||||
<div class="rounded-circle bg-primary-subtle text-primary d-inline-flex p-3 mb-2">
|
||||
|
|
@ -488,9 +513,9 @@
|
|||
|
||||
<div id="emptyListMessage" class="text-center py-5 d-none">
|
||||
<div class="rounded-circle bg-light d-inline-flex p-3 mb-3 text-muted">
|
||||
<i class="bi bi-search fs-1"></i>
|
||||
<i class="bi bi-search fs-1" id="emptyListIcon"></i>
|
||||
</div>
|
||||
<h6 class="fw-bold text-secondary">{% translate "Aucun asset trouvé" %}</h6>
|
||||
<h6 class="fw-bold text-secondary" id="emptyListTitle">{% translate "Aucun asset trouvé" %}</h6>
|
||||
<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">
|
||||
|
|
@ -594,6 +619,8 @@
|
|||
let noResultsAlertTimeout = null;
|
||||
let mapMoveTimeout = null;
|
||||
let lastResults = [];
|
||||
let lastLimitReached = false;
|
||||
let lastTotalCount = 0;
|
||||
|
||||
// Éléments DOM
|
||||
const searchInput = document.getElementById('assetSearchInput');
|
||||
|
|
@ -608,11 +635,15 @@
|
|||
const viewContainerList = document.getElementById('viewContainerList');
|
||||
const assetsListContainer = document.getElementById('assetsListContainer');
|
||||
const emptyListMessage = document.getElementById('emptyListMessage');
|
||||
const emptyListIcon = document.getElementById('emptyListIcon');
|
||||
const emptyListTitle = document.getElementById('emptyListTitle');
|
||||
const noThematicSelectedMessage = document.getElementById('noThematicSelectedMessage');
|
||||
const mapThematicPrompt = document.getElementById('mapThematicPrompt');
|
||||
const filterLimitAlert = document.getElementById('filterLimitAlert');
|
||||
const filterLimitAlertText = document.getElementById('filterLimitAlertText');
|
||||
const noResultsInZoneAlert = document.getElementById('noResultsInZoneAlert');
|
||||
const btnResetToInitialView = document.getElementById('btnResetToInitialView');
|
||||
const btnCloseNoResultsAlert = document.getElementById('btnCloseNoResultsAlert');
|
||||
const btnInitialView = document.getElementById('btnInitialView');
|
||||
const btnListInitialView = document.getElementById('btnListInitialView');
|
||||
const emptyListSubtitle = document.getElementById('emptyListSubtitle');
|
||||
|
|
@ -770,10 +801,6 @@
|
|||
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
|
||||
|
|
@ -930,12 +957,19 @@
|
|||
// 4. Rendu des résultats sur Carte et dans la Liste
|
||||
function renderResults(data, fromMapMove = false, options = {}) {
|
||||
lastResults = data.results || [];
|
||||
lastLimitReached = Boolean(data.limit_reached);
|
||||
lastTotalCount = data.total_count || 0;
|
||||
|
||||
// Gestion de la limite de 500 assets atteinte
|
||||
if (data.limit_reached) {
|
||||
if (filterLimitAlert) filterLimitAlert.classList.remove('d-none');
|
||||
if (limitNotice) limitNotice.classList.remove('d-none');
|
||||
resultsCountEl.textContent = `${data.count} / ${data.total_count}`;
|
||||
if (filterLimitAlert) {
|
||||
filterLimitAlert.classList.remove('d-none');
|
||||
if (filterLimitAlertText && data.total_count) {
|
||||
filterLimitAlertText.textContent = `{% translate "Plus de 500 assets correspondent" %} (${data.total_count} {% translate "au total" %}). {% translate "Veuillez zoomer sur la carte ou affiner vos critères de recherche pour les afficher." %}`;
|
||||
}
|
||||
}
|
||||
if (limitNotice) limitNotice.classList.add('d-none');
|
||||
resultsCountEl.textContent = `${data.total_count}`;
|
||||
} else {
|
||||
if (filterLimitAlert) filterLimitAlert.classList.add('d-none');
|
||||
if (limitNotice) limitNotice.classList.add('d-none');
|
||||
|
|
@ -952,10 +986,20 @@
|
|||
handleNoResultsInZoneAlert(data, options);
|
||||
}
|
||||
|
||||
function renderListView(results) {
|
||||
function renderListView(results = lastResults) {
|
||||
assetsListContainer.innerHTML = '';
|
||||
if (!results || results.length === 0) {
|
||||
emptyListMessage.classList.remove('d-none');
|
||||
if (lastLimitReached) {
|
||||
if (emptyListIcon) emptyListIcon.className = 'bi bi-zoom-in fs-1 text-warning';
|
||||
if (emptyListTitle) emptyListTitle.textContent = "{% translate 'Limite de 500 assets atteinte' %}";
|
||||
if (emptyListSubtitle) {
|
||||
emptyListSubtitle.textContent = `{% translate "Plus de 500 assets correspondent" %}${lastTotalCount ? ` (${lastTotalCount} {% translate "au total" %})` : ''}. {% translate "Veuillez zoomer sur la carte ou affiner vos critères de recherche pour afficher la liste." %}`;
|
||||
}
|
||||
if (btnListInitialView) btnListInitialView.classList.add('d-none');
|
||||
} else {
|
||||
if (emptyListIcon) emptyListIcon.className = 'bi bi-search fs-1 text-muted';
|
||||
if (emptyListTitle) emptyListTitle.textContent = "{% translate 'Aucun asset trouvé' %}";
|
||||
if (btnListInitialView) {
|
||||
if (currentQuery && !isAtMaxBbox()) {
|
||||
btnListInitialView.classList.remove('d-none');
|
||||
|
|
@ -969,6 +1013,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
emptyListMessage.classList.add('d-none');
|
||||
|
|
@ -1192,6 +1237,9 @@
|
|||
// Vérifier si la vue courante correspond à la boîte englobante maximale (vue initiale)
|
||||
function isAtMaxBbox() {
|
||||
if (!map) return true;
|
||||
// Si la recherche est restreinte par une boîte englobante (zone visible), elle n'est pas à la bbox maximale
|
||||
if (isBboxFiltered) return false;
|
||||
|
||||
const currentZoom = map.getZoom();
|
||||
const currentCenter = map.getCenter();
|
||||
|
||||
|
|
@ -1199,13 +1247,8 @@
|
|||
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)) {
|
||||
// Si le zoom est plus grand que le zoom initial (+0.5) ou si le centre s'est décalé (> 0.025 deg)
|
||||
if (zoomDiff > 0.5 || dLng > 0.025 || dLat > 0.025) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1240,6 +1283,12 @@
|
|||
const count = data.count || (data.results ? data.results.length : 0);
|
||||
const hasQuery = Boolean(currentQuery && currentQuery.trim().length > 0);
|
||||
|
||||
// Si la limite de 500 est atteinte, ce n'est pas une zone vide : masquer l'alerte de zone vide
|
||||
if (data.limit_reached) {
|
||||
hideNoResultsInZoneAlert();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la recherche a retourné des résultats ou si aucune recherche textuelle n'est active, masquer l'alerte
|
||||
if (!hasQuery || count > 0) {
|
||||
hideNoResultsInZoneAlert();
|
||||
|
|
@ -1263,9 +1312,9 @@
|
|||
if (currentQuery && currentQuery.trim().length >= 2 && lastResults.length === 0 && !isAtMaxBbox()) {
|
||||
showNoResultsInZoneAlert();
|
||||
}
|
||||
}, 850);
|
||||
}, 500);
|
||||
} else {
|
||||
// Soumission explicite (touche Entrée ou clic loupe)
|
||||
// Soumission explicite (touche Entrée ou clic loupe) ou déplacement de carte
|
||||
showNoResultsInZoneAlert();
|
||||
}
|
||||
}
|
||||
|
|
@ -1300,6 +1349,9 @@
|
|||
if (btnResetToInitialView) {
|
||||
btnResetToInitialView.addEventListener('click', returnToInitialView);
|
||||
}
|
||||
if (btnCloseNoResultsAlert) {
|
||||
btnCloseNoResultsAlert.addEventListener('click', hideNoResultsInZoneAlert);
|
||||
}
|
||||
if (btnInitialView) {
|
||||
btnInitialView.addEventListener('click', returnToInitialView);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,11 +317,13 @@ class MobileAssetDetailTests(TestCase):
|
|||
self.assertIn("ARB-TEST-001", codes)
|
||||
self.assertIn("ARB-SHRUB-001", codes)
|
||||
|
||||
# 3. Test limit reached logic with limit=1
|
||||
# 3. Test limit reached logic with limit=1: when limit is reached, return empty results and empty geojson
|
||||
response = self.client.get(search_url, {"thematic": "nature", "limit": "1"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["count"], 0)
|
||||
self.assertEqual(data["results"], [])
|
||||
self.assertEqual(data["geojson"]["features"], [])
|
||||
self.assertEqual(data["limit"], 1)
|
||||
self.assertTrue(data["limit_reached"])
|
||||
self.assertGreater(data["total_count"], 1)
|
||||
|
|
|
|||
|
|
@ -1348,8 +1348,7 @@ def mobile_assets_search(request):
|
|||
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1.0 - a)))
|
||||
return R * c
|
||||
|
||||
results = []
|
||||
features = []
|
||||
model_querysets = []
|
||||
total_count = 0
|
||||
|
||||
t_code = thematic_filter
|
||||
|
|
@ -1386,10 +1385,30 @@ def mobile_assets_search(request):
|
|||
# Nombre total d'assets correspondants (pour savoir si limite atteinte)
|
||||
count_for_model = qs.count()
|
||||
total_count += count_for_model
|
||||
model_querysets.append((model_key, model_class, qs))
|
||||
|
||||
remaining_slots = limit - len(results)
|
||||
if remaining_slots > 0:
|
||||
items = list(qs[:remaining_slots])
|
||||
limit_reached = total_count > limit
|
||||
|
||||
# Si la limite est atteinte, ne renvoyer aucun asset ni GeoJSON pour ne pas induire en erreur et alléger la requête
|
||||
if limit_reached:
|
||||
return JsonResponse({
|
||||
'count': 0,
|
||||
'total_count': total_count,
|
||||
'limit': limit,
|
||||
'limit_reached': True,
|
||||
'thematic': t_code,
|
||||
'results': [],
|
||||
'geojson': {
|
||||
'type': 'FeatureCollection',
|
||||
'features': [],
|
||||
}
|
||||
})
|
||||
|
||||
results = []
|
||||
features = []
|
||||
|
||||
for model_key, model_class, qs in model_querysets:
|
||||
items = list(qs)
|
||||
for item in items:
|
||||
lat = None
|
||||
lon = None
|
||||
|
|
@ -1483,19 +1502,16 @@ def mobile_assets_search(request):
|
|||
else:
|
||||
results.sort(key=lambda x: x['code'])
|
||||
|
||||
results = results[:limit]
|
||||
limit_reached = total_count > limit
|
||||
|
||||
return JsonResponse({
|
||||
'count': len(results),
|
||||
'total_count': total_count,
|
||||
'limit': limit,
|
||||
'limit_reached': limit_reached,
|
||||
'limit_reached': False,
|
||||
'thematic': t_code,
|
||||
'results': results,
|
||||
'geojson': {
|
||||
'type': 'FeatureCollection',
|
||||
'features': features[:limit],
|
||||
'features': features,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue