feat: enhance mobile asset map UI with floating alerts and updated limit handling

This commit is contained in:
kdeterme 2026-09-13 12:33:14 +02:00
parent 4b1bc59f1e
commit 2ada4f88b7
3 changed files with 204 additions and 134 deletions

View file

@ -141,6 +141,30 @@
height: 100%; 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 */ /* Bouton flottant GPS */
.map-gps-btn { .map-gps-btn {
position: absolute; 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"> <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> <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;"> <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>
</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">
@ -451,6 +462,20 @@
<div id="assets-map-wrapper"> <div id="assets-map-wrapper">
<div id="assets-map"></div> <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 --> <!-- 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 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"> <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 id="emptyListMessage" class="text-center py-5 d-none">
<div class="rounded-circle bg-light d-inline-flex p-3 mb-3 text-muted"> <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> </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> <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"> <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"> <button type="button" class="btn btn-outline-primary btn-sm rounded-3" id="btnResetFilters">
@ -594,6 +619,8 @@
let noResultsAlertTimeout = null; let noResultsAlertTimeout = null;
let mapMoveTimeout = null; let mapMoveTimeout = null;
let lastResults = []; let lastResults = [];
let lastLimitReached = false;
let lastTotalCount = 0;
// Éléments DOM // Éléments DOM
const searchInput = document.getElementById('assetSearchInput'); const searchInput = document.getElementById('assetSearchInput');
@ -608,11 +635,15 @@
const viewContainerList = document.getElementById('viewContainerList'); const viewContainerList = document.getElementById('viewContainerList');
const assetsListContainer = document.getElementById('assetsListContainer'); const assetsListContainer = document.getElementById('assetsListContainer');
const emptyListMessage = document.getElementById('emptyListMessage'); const emptyListMessage = document.getElementById('emptyListMessage');
const emptyListIcon = document.getElementById('emptyListIcon');
const emptyListTitle = document.getElementById('emptyListTitle');
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 filterLimitAlertText = document.getElementById('filterLimitAlertText');
const noResultsInZoneAlert = document.getElementById('noResultsInZoneAlert'); const noResultsInZoneAlert = document.getElementById('noResultsInZoneAlert');
const btnResetToInitialView = document.getElementById('btnResetToInitialView'); const btnResetToInitialView = document.getElementById('btnResetToInitialView');
const btnCloseNoResultsAlert = document.getElementById('btnCloseNoResultsAlert');
const btnInitialView = document.getElementById('btnInitialView'); const btnInitialView = document.getElementById('btnInitialView');
const btnListInitialView = document.getElementById('btnListInitialView'); const btnListInitialView = document.getElementById('btnListInitialView');
const emptyListSubtitle = document.getElementById('emptyListSubtitle'); const emptyListSubtitle = document.getElementById('emptyListSubtitle');
@ -770,10 +801,6 @@
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
@ -930,12 +957,19 @@
// 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, options = {}) { function renderResults(data, fromMapMove = false, options = {}) {
lastResults = data.results || []; lastResults = data.results || [];
lastLimitReached = Boolean(data.limit_reached);
lastTotalCount = data.total_count || 0;
// Gestion de la limite de 500 assets atteinte // Gestion de la limite de 500 assets atteinte
if (data.limit_reached) { if (data.limit_reached) {
if (filterLimitAlert) filterLimitAlert.classList.remove('d-none'); if (filterLimitAlert) {
if (limitNotice) limitNotice.classList.remove('d-none'); filterLimitAlert.classList.remove('d-none');
resultsCountEl.textContent = `${data.count} / ${data.total_count}`; 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 { } else {
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');
@ -952,20 +986,31 @@
handleNoResultsInZoneAlert(data, options); handleNoResultsInZoneAlert(data, options);
} }
function renderListView(results) { function renderListView(results = lastResults) {
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 (lastLimitReached) {
if (currentQuery && !isAtMaxBbox()) { if (emptyListIcon) emptyListIcon.className = 'bi bi-zoom-in fs-1 text-warning';
btnListInitialView.classList.remove('d-none'); if (emptyListTitle) emptyListTitle.textContent = "{% translate 'Limite de 500 assets atteinte' %}";
if (emptyListSubtitle) { if (emptyListSubtitle) {
emptyListSubtitle.textContent = "{% translate 'Aucun asset trouvé dans cette zone pour votre recherche.' %}"; 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." %}`;
} }
} else { if (btnListInitialView) btnListInitialView.classList.add('d-none');
btnListInitialView.classList.add('d-none'); } else {
if (emptyListSubtitle) { if (emptyListIcon) emptyListIcon.className = 'bi bi-search fs-1 text-muted';
emptyListSubtitle.textContent = "{% translate 'Modifiez votre recherche ou zoomez sur une autre zone.' %}"; if (emptyListTitle) emptyListTitle.textContent = "{% translate 'Aucun asset trouvé' %}";
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.' %}";
}
} }
} }
} }
@ -1192,6 +1237,9 @@
// Vérifier si la vue courante correspond à la boîte englobante maximale (vue initiale) // Vérifier si la vue courante correspond à la boîte englobante maximale (vue initiale)
function isAtMaxBbox() { function isAtMaxBbox() {
if (!map) return true; 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 currentZoom = map.getZoom();
const currentCenter = map.getCenter(); const currentCenter = map.getCenter();
@ -1199,13 +1247,8 @@
const dLat = Math.abs(currentCenter.lat - defaultCenter[1]); const dLat = Math.abs(currentCenter.lat - defaultCenter[1]);
const zoomDiff = currentZoom - defaultZoom; 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) // 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.02 || dLat > 0.02) { if (zoomDiff > 0.5 || dLng > 0.025 || dLat > 0.025) {
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 false;
} }
@ -1240,6 +1283,12 @@
const count = data.count || (data.results ? data.results.length : 0); const count = data.count || (data.results ? data.results.length : 0);
const hasQuery = Boolean(currentQuery && currentQuery.trim().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 // Si la recherche a retourné des résultats ou si aucune recherche textuelle n'est active, masquer l'alerte
if (!hasQuery || count > 0) { if (!hasQuery || count > 0) {
hideNoResultsInZoneAlert(); hideNoResultsInZoneAlert();
@ -1263,9 +1312,9 @@
if (currentQuery && currentQuery.trim().length >= 2 && lastResults.length === 0 && !isAtMaxBbox()) { if (currentQuery && currentQuery.trim().length >= 2 && lastResults.length === 0 && !isAtMaxBbox()) {
showNoResultsInZoneAlert(); showNoResultsInZoneAlert();
} }
}, 850); }, 500);
} else { } else {
// Soumission explicite (touche Entrée ou clic loupe) // Soumission explicite (touche Entrée ou clic loupe) ou déplacement de carte
showNoResultsInZoneAlert(); showNoResultsInZoneAlert();
} }
} }
@ -1300,6 +1349,9 @@
if (btnResetToInitialView) { if (btnResetToInitialView) {
btnResetToInitialView.addEventListener('click', returnToInitialView); btnResetToInitialView.addEventListener('click', returnToInitialView);
} }
if (btnCloseNoResultsAlert) {
btnCloseNoResultsAlert.addEventListener('click', hideNoResultsInZoneAlert);
}
if (btnInitialView) { if (btnInitialView) {
btnInitialView.addEventListener('click', returnToInitialView); btnInitialView.addEventListener('click', returnToInitialView);
} }

View file

@ -317,11 +317,13 @@ class MobileAssetDetailTests(TestCase):
self.assertIn("ARB-TEST-001", codes) self.assertIn("ARB-TEST-001", codes)
self.assertIn("ARB-SHRUB-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"}) response = self.client.get(search_url, {"thematic": "nature", "limit": "1"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() 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.assertEqual(data["limit"], 1)
self.assertTrue(data["limit_reached"]) self.assertTrue(data["limit_reached"])
self.assertGreater(data["total_count"], 1) self.assertGreater(data["total_count"], 1)

View file

@ -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))) c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1.0 - a)))
return R * c return R * c
results = [] model_querysets = []
features = []
total_count = 0 total_count = 0
t_code = thematic_filter t_code = thematic_filter
@ -1386,116 +1385,133 @@ def mobile_assets_search(request):
# Nombre total d'assets correspondants (pour savoir si limite atteinte) # Nombre total d'assets correspondants (pour savoir si limite atteinte)
count_for_model = qs.count() count_for_model = qs.count()
total_count += count_for_model total_count += count_for_model
model_querysets.append((model_key, model_class, qs))
remaining_slots = limit - len(results) limit_reached = total_count > limit
if remaining_slots > 0:
items = list(qs[:remaining_slots]) # Si la limite est atteinte, ne renvoyer aucun asset ni GeoJSON pour ne pas induire en erreur et alléger la requête
for item in items: if limit_reached:
lat = None return JsonResponse({
lon = None 'count': 0,
if hasattr(item, 'lat') and item.lat is not None and hasattr(item, 'lon') and item.lon is not None: '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
if hasattr(item, 'lat') and item.lat is not None and hasattr(item, 'lon') and item.lon is not None:
try:
lat = float(str(item.lat).replace(',', '.'))
lon = float(str(item.lon).replace(',', '.'))
except (ValueError, TypeError):
pass
elif hasattr(item, 'geom') and item.geom:
try:
g = item.geom.clone()
g.transform(4326)
centroid = g.centroid
lon = float(centroid.x)
lat = float(centroid.y)
except Exception:
pass
elif hasattr(item, 'location') and item.location:
loc = item.location
if hasattr(loc, 'lat') and loc.lat and hasattr(loc, 'lon') and loc.lon:
try: try:
lat = float(str(item.lat).replace(',', '.')) lat = float(str(loc.lat).replace(',', '.'))
lon = float(str(item.lon).replace(',', '.')) lon = float(str(loc.lon).replace(',', '.'))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
elif hasattr(item, 'geom') and item.geom:
try:
g = item.geom.clone()
g.transform(4326)
centroid = g.centroid
lon = float(centroid.x)
lat = float(centroid.y)
except Exception:
pass
elif hasattr(item, 'location') and item.location:
loc = item.location
if hasattr(loc, 'lat') and loc.lat and hasattr(loc, 'lon') and loc.lon:
try:
lat = float(str(loc.lat).replace(',', '.'))
lon = float(str(loc.lon).replace(',', '.'))
except (ValueError, TypeError):
pass
distance_m = None distance_m = None
if user_lat is not None and user_lon is not None and lat is not None and lon is not None: if user_lat is not None and user_lon is not None and lat is not None and lon is not None:
try: try:
distance_m = round(_haversine(user_lat, user_lon, lat, lon)) distance_m = round(_haversine(user_lat, user_lon, lat, lon))
except Exception: except Exception:
distance_m = None distance_m = None
item_name = ( item_name = (
getattr(item, f'name_{lang}', None) or getattr(item, f'name_{lang}', None) or
getattr(item, 'name_fr', None) or getattr(item, 'name_fr', None) or
(getattr(item.model, f'name_{lang}', None) or getattr(item.model, 'name_fr', None) if getattr(item, 'model', None) else '') or (getattr(item.model, f'name_{lang}', None) or getattr(item.model, 'name_fr', None) if getattr(item, 'model', None) else '') or
'' ''
) )
status_val = getattr(item, 'status', 'active') status_val = getattr(item, 'status', 'active')
status_display = item.get_status_display() if hasattr(item, 'get_status_display') else status_val status_display = item.get_status_display() if hasattr(item, 'get_status_display') else status_val
element_type = get_element_type_label(model_key) element_type = get_element_type_label(model_key)
detail_url = reverse('mobile:mobile_asset_detail_direct', kwargs={'asset_model': model_key, 'asset_id': item.id}) detail_url = reverse('mobile:mobile_asset_detail_direct', kwargs={'asset_model': model_key, 'asset_id': item.id})
res_item = { res_item = {
'id': item.id, 'id': item.id,
'code': getattr(item, 'code', f'#{item.id}'), 'code': getattr(item, 'code', f'#{item.id}'),
'name': item_name, 'name': item_name,
'thematic': t_code, 'thematic': t_code,
'thematic_name': thematic_name, 'thematic_name': thematic_name,
'thematic_icon': thematic_icon, 'thematic_icon': thematic_icon,
'model_key': model_key, 'model_key': model_key,
'element_type': element_type, 'element_type': element_type,
'status': status_val, 'status': status_val,
'status_display': status_display, 'status_display': status_display,
'lat': lat, 'lat': lat,
'lon': lon, 'lon': lon,
'distance_m': distance_m, 'distance_m': distance_m,
'detail_url': detail_url, 'detail_url': detail_url,
} }
results.append(res_item) results.append(res_item)
if lon is not None and lat is not None: if lon is not None and lat is not None:
features.append({ features.append({
'type': 'Feature', 'type': 'Feature',
'id': f"{model_key}_{item.id}", 'id': f"{model_key}_{item.id}",
'geometry': { 'geometry': {
'type': 'Point', 'type': 'Point',
'coordinates': [lon, lat], 'coordinates': [lon, lat],
}, },
'properties': { 'properties': {
'id': item.id, 'id': item.id,
'code': getattr(item, 'code', f'#{item.id}'), 'code': getattr(item, 'code', f'#{item.id}'),
'name': item_name, 'name': item_name,
'thematic': t_code, 'thematic': t_code,
'thematic_name': thematic_name, 'thematic_name': thematic_name,
'thematic_icon': thematic_icon, 'thematic_icon': thematic_icon,
'model_key': model_key, 'model_key': model_key,
'element_type': element_type, 'element_type': element_type,
'status': status_val, 'status': status_val,
'status_display': status_display, 'status_display': status_display,
'distance_m': distance_m, 'distance_m': distance_m,
'detail_url': detail_url, 'detail_url': detail_url,
} }
}) })
if user_lat is not None and user_lon is not None: if user_lat is not None and user_lon is not None:
results.sort(key=lambda x: (x['distance_m'] if x['distance_m'] is not None else 999999999, x['code'])) results.sort(key=lambda x: (x['distance_m'] if x['distance_m'] is not None else 999999999, x['code']))
else: else:
results.sort(key=lambda x: x['code']) results.sort(key=lambda x: x['code'])
results = results[:limit]
limit_reached = total_count > limit
return JsonResponse({ return JsonResponse({
'count': len(results), 'count': len(results),
'total_count': total_count, 'total_count': total_count,
'limit': limit, 'limit': limit,
'limit_reached': limit_reached, 'limit_reached': False,
'thematic': t_code, 'thematic': t_code,
'results': results, 'results': results,
'geojson': { 'geojson': {
'type': 'FeatureCollection', 'type': 'FeatureCollection',
'features': features[:limit], 'features': features,
} }
}) })