feat: add vector layers and GeoJSON support for polygons and lines on mobile map

This commit is contained in:
kdeterme 2026-09-13 14:16:57 +02:00
parent 35a20a2936
commit d33b49647b
5 changed files with 496 additions and 137 deletions

View file

@ -724,6 +724,124 @@
];
}
/**
* MapLibre layer definition for polygon asset fills (surfacic elements: green surfaces, parking spots, etc.).
*/
function getAssetsFillLayer(sourceId = 'assets') {
return {
id: 'assets-fill',
type: 'fill',
source: sourceId,
filter: ['any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon']
],
paint: {
'fill-color': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#fef08a',
['==', ['get', 'asset_type'], 'naturerwiasset'],
[
'match', ['get', 'structure_type'],
['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation'], '#8d6e63',
['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle'], '#009688',
['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre'], '#0277bd',
'#0288d1'
],
['==', ['get', 'thematic'], 'nature'],
['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(),
getThematicColorExpr()
],
'fill-opacity': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
['==', ['get', 'thematic'], 'parking'], 0.38,
0.65
],
'fill-antialias': false,
'fill-outline-color': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#eab308',
'#00000000'
],
},
minzoom: 8,
};
}
/**
* MapLibre layer definition for polygon asset outlines.
*/
function getAssetsPolygonOutlineLayer(sourceId = 'assets') {
return {
id: 'assets-polygon-outline',
type: 'line',
source: sourceId,
filter: ['any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon']
],
paint: {
'line-color': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#ca8a04',
['==', ['get', 'thematic'], 'nature'],
['coalesce', ['get', 'color'], '#1b5e20'],
['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(),
getThematicColorExpr()
],
'line-width': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
2
],
'line-opacity': 0.95,
},
minzoom: 8,
};
}
/**
* MapLibre layer definition for linear assets (roads, sidewalks, ditches, cables, etc.).
*/
function getAssetsLineLayer(sourceId = 'assets') {
return {
id: 'assets-line',
type: 'line',
source: sourceId,
filter: ['any',
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString']
],
paint: {
'line-color': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], '#eab308',
['==', ['get', 'asset_type'], 'naturerwiasset'],
[
'match', ['get', 'structure_type'],
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif'], '#8d6e63',
['noue', 'jardin de pluie', 'bande filtrante'], '#009688',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein'], '#0277bd',
'#0288d1'
],
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()]
],
'line-width': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6,
3.5
],
'line-opacity': 0.88,
},
minzoom: 8,
};
}
// ─── 6. Public Export ───────────────────────────────────────────────────────
global.AssetSymbology = {
THEMATIC_CONFIG,
@ -738,6 +856,9 @@
getThematicColorExpr,
getIconImageExpr,
getParkingColorExpr,
getAssetsFillLayer,
getAssetsPolygonOutlineLayer,
getAssetsLineLayer,
};
// Ensure UNIFIED_ICON_URLS is globally available if not already defined

View file

@ -311,7 +311,9 @@ const _unified_locations_fill = {
};
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
const _unified_assets_fill = {
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
? AssetSymbology.getAssetsFillLayer('assets')
: {
id: 'assets-fill',
type: 'fill',
source: 'assets',
@ -321,36 +323,19 @@ const _unified_assets_fill = {
],
paint: {
'fill-color': [
// Nature surfaces use a per-feature 'color' property; all others
// use the thematic colour.
'case',
['==', ['get', 'asset_type'], 'naturerwiasset'],
[
'match', ['get', 'structure_type'],
['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation'], '#8d6e63', // earthy gravel brown
['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle'], '#009688', // teal vegetated
['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre'], '#0277bd', // deep water basins
'#0288d1' // fallback blue
],
['==', ['get', 'thematic'], 'nature'],
['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'],
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : [
'match', ['get', 'spot_type'],
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
'delivery', '#f59e0b', // Orange/Ambre camion livraison
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
]),
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
THEMATIC_COLOR_EXPR
],
'fill-opacity': [
'case',
['==', ['get', 'thematic'], 'parking'], 0.35,
0.7
],
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
'fill-antialias': false,
'fill-outline-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
@ -358,7 +343,7 @@ const _unified_assets_fill = {
],
},
minzoom: 8,
};
};
// ─── Layer 3 — location outline (line layer for polygon location borders) ─────
const _unified_locations_outline = {
@ -698,7 +683,9 @@ const _unified_sign_poles_orientation = {
};
// ─── Layer 3b — asset line (line geometries for linear assets like cables, ditches) ──
const _unified_assets_line = {
const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsLineLayer)
? AssetSymbology.getAssetsLineLayer('assets')
: {
id: 'assets-line',
type: 'line',
source: 'assets',
@ -709,14 +696,6 @@ const _unified_assets_line = {
paint: {
'line-color': [
'case',
['==', ['get', 'asset_type'], 'naturerwiasset'],
[
'match', ['get', 'structure_type'],
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif'], '#8d6e63',
['noue', 'jardin de pluie', 'bande filtrante'], '#009688',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein'], '#0277bd',
'#0288d1'
],
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
THEMATIC_COLOR_EXPR
],
@ -728,7 +707,7 @@ const _unified_assets_line = {
'line-opacity': 0.8,
},
minzoom: 8,
};
};
// ─── Layer 4b — road assets circles (point roadway/sidewalk assets) ──────────
const _unified_assets_point = {
@ -772,7 +751,9 @@ const _unified_assets_point = {
};
// ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ───
const _unified_assets_polygon_outline = {
const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsPolygonOutlineLayer)
? AssetSymbology.getAssetsPolygonOutlineLayer('assets')
: {
id: 'assets-polygon-outline',
type: 'line',
source: 'assets',
@ -785,15 +766,9 @@ const _unified_assets_polygon_outline = {
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
['==', ['get', 'thematic'], 'parking'],
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : [
'match', ['get', 'spot_type'],
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
'delivery', '#f59e0b', // Orange/Ambre camion livraison
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
]),
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
THEMATIC_COLOR_EXPR
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 4,
@ -802,7 +777,7 @@ const _unified_assets_polygon_outline = {
'line-opacity': 0.9,
},
minzoom: 8,
};
};
// ─── Ordered layer list ───────────────────────────────────────────────────────
// MapLibre renders layers in insertion order, so the array below defines the

View file

@ -641,8 +641,11 @@
let noResultsAlertTimeout = null;
let mapMoveTimeout = null;
let lastResults = [];
let lastGeojson = { type: 'FeatureCollection', features: [] };
let lastLimitReached = false;
let lastTotalCount = 0;
let lastSelectedFeatureId = null;
let layersConfigured = false;
// Éléments DOM
const searchInput = document.getElementById('assetSearchInput');
@ -821,7 +824,57 @@
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-left');
map.on('click', () => {
// Couches vectorielles pour les éléments surfaciques (polygones) et linéaires (lignes)
function setupMapLayers() {
if (!map || !map.isStyleLoaded()) return;
if (!map.getSource('assets')) {
map.addSource('assets', {
type: 'geojson',
data: lastGeojson || { type: 'FeatureCollection', features: [] },
promoteId: 'feature_id'
});
}
if (typeof AssetSymbology !== 'undefined') {
if (!map.getLayer('assets-fill')) {
map.addLayer(AssetSymbology.getAssetsFillLayer('assets'));
}
if (!map.getLayer('assets-polygon-outline')) {
map.addLayer(AssetSymbology.getAssetsPolygonOutlineLayer('assets'));
}
if (!map.getLayer('assets-line')) {
map.addLayer(AssetSymbology.getAssetsLineLayer('assets'));
}
if (!layersConfigured) {
layersConfigured = true;
['assets-fill', 'assets-line'].forEach(layerId => {
map.on('click', layerId, (e) => {
if (e.features && e.features.length > 0) {
const feature = e.features[0];
const props = feature.properties || {};
const featId = feature.id || props.feature_id;
const asset = lastResults.find(a => String(a.feature_id) === String(featId) || (a.model_key === props.model_key && String(a.id) === String(props.id))) || props;
if (e.originalEvent) {
e.originalEvent._handledByAssetLayer = true;
}
openBottomSheet(asset);
}
});
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
});
}
}
}
map.on('click', (e) => {
if (e.originalEvent && e.originalEvent._handledByAssetLayer) return;
closeBottomSheet();
});
@ -853,8 +906,21 @@
});
map.on('load', () => {
if (lastResults.length > 0 && activeMarkers.length === 0) {
renderMapView(lastResults, isBboxFiltered);
layersConfigured = false;
setupMapLayers();
if (lastResults.length > 0) {
renderMapView(lastResults, lastGeojson, isBboxFiltered);
}
});
map.on('styledata', () => {
if (map.isStyleLoaded() && !map.getSource('assets')) {
layersConfigured = false;
setupMapLayers();
if (lastGeojson && lastGeojson.features && lastGeojson.features.length > 0) {
const source = map.getSource('assets');
if (source) source.setData(lastGeojson);
}
}
});
}
@ -916,6 +982,10 @@
assetsListContainer.innerHTML = '';
activeMarkers.forEach(m => m.remove());
activeMarkers = [];
lastGeojson = { type: 'FeatureCollection', features: [] };
if (map && map.getSource('assets')) {
map.getSource('assets').setData(lastGeojson);
}
return;
}
@ -981,6 +1051,7 @@
// 4. Rendu des résultats sur Carte et dans la Liste
function renderResults(data, fromMapMove = false, options = {}) {
lastResults = data.results || [];
lastGeojson = data.geojson || { type: 'FeatureCollection', features: [] };
lastLimitReached = Boolean(data.limit_reached);
lastTotalCount = data.total_count || 0;
@ -1003,8 +1074,8 @@
// Synchronisation de la Liste (toujours à jour avec la zone zoomée ou les filtres)
renderListView(lastResults);
// Rendu Carte (mise à jour des marqueurs, sans modifier la visualisation)
renderMapView(lastResults, fromMapMove);
// Rendu Carte (mise à jour des marqueurs et des couches vectorielles)
renderMapView(lastResults, lastGeojson, fromMapMove);
// Gestion du message invitant à revenir à la vue initiale en cas de recherche sans résultat
handleNoResultsInZoneAlert(data, options);
@ -1078,16 +1149,33 @@
});
}
function renderMapView(results, fromMapMove = false) {
function renderMapView(results = lastResults, geojson = lastGeojson, fromMapMove = false) {
if (!map) return;
if (typeof geojson === 'boolean') {
fromMapMove = geojson;
geojson = lastGeojson;
}
if (!geojson) geojson = lastGeojson;
// Mettre à jour la source vectorielle pour les polygones et polylignes
if (map.isStyleLoaded()) {
const source = map.getSource('assets');
if (source) {
source.setData(geojson || { type: 'FeatureCollection', features: [] });
}
}
// Supprimer les anciens marqueurs
activeMarkers.forEach(m => m.remove());
activeMarkers = [];
results.forEach(asset => {
(results || []).forEach(asset => {
if (asset.lon === null || asset.lat === null) return;
// N'afficher un marqueur DOM HTML que pour les géométries ponctuelles
const isPunctual = !asset.geom_type || asset.geom_type === 'Point' || asset.geom_type === 'MultiPoint';
if (!isPunctual) return;
const symbology = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getAssetSymbology(asset)
: { color: THEMATIC_COLORS[asset.thematic] || '#2f3e8b', iconClass: asset.thematic_icon || 'bi-pin-map' };
@ -1119,6 +1207,24 @@
// 5. Volet d'aperçu inférieur (Bottom Sheet)
function openBottomSheet(asset) {
if (!asset) return;
// Mettre en surbrillance sur la carte (pour surfaces polygonales et polylignes)
if (map && map.getSource('assets')) {
if (lastSelectedFeatureId) {
try {
map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false });
} catch (e) {}
}
const featId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null);
if (featId) {
lastSelectedFeatureId = featId;
try {
map.setFeatureState({ source: 'assets', id: featId }, { selected: true });
} catch (e) {}
}
}
sheetCode.textContent = asset.code || '-';
sheetName.textContent = asset.name || asset.element_type || '';
sheetThematicName.textContent = asset.thematic_name || '';
@ -1139,16 +1245,16 @@
sheetStatusBadge.textContent = asset.status_display || asset.status || 'Actif';
sheetElementType.textContent = asset.element_type || '';
if (asset.distance_m !== null) {
if (asset.distance_m !== null && asset.distance_m !== undefined) {
sheetDistanceBadge.classList.remove('d-none');
sheetDistanceText.textContent = formatDistance(asset.distance_m);
} else {
sheetDistanceBadge.classList.add('d-none');
}
sheetDetailLink.href = asset.detail_url;
sheetDetailLink.href = asset.detail_url || '#';
if (asset.lat !== null && asset.lon !== null) {
if (asset.lat !== null && asset.lat !== undefined && asset.lon !== null && asset.lon !== undefined) {
sheetRouteLink.href = `https://www.google.com/maps/dir/?api=1&destination=${asset.lat},${asset.lon}`;
sheetRouteLink.classList.remove('d-none');
} else {
@ -1157,14 +1263,37 @@
bottomSheet.classList.add('active');
// Centrer la carte sur le marqueur
if (map && asset.lon !== null && asset.lat !== null) {
map.easeTo({ center: [asset.lon, asset.lat], zoom: Math.max(map.getZoom(), 15), offset: [0, 80] });
// Cadrage ou recentrage sur la carte
if (map) {
let bbox = asset.bbox;
if (typeof bbox === 'string') {
try { bbox = JSON.parse(bbox); } catch (e) { bbox = null; }
}
if (Array.isArray(bbox) && bbox.length === 4 &&
isFinite(bbox[0]) && isFinite(bbox[1]) && isFinite(bbox[2]) && isFinite(bbox[3]) &&
(bbox[2] > bbox[0] || bbox[3] > bbox[1])) {
// Surface ou ligne : zoomer sur son emprise
isProgrammaticMove = true;
map.fitBounds(
[[bbox[0], bbox[1]], [bbox[2], bbox[3]]],
{ padding: { top: 60, bottom: 220, left: 40, right: 40 }, maxZoom: 18 }
);
} else if (asset.lon !== null && asset.lat !== null && isFinite(asset.lon) && isFinite(asset.lat)) {
isProgrammaticMove = true;
map.easeTo({ center: [asset.lon, asset.lat], zoom: Math.max(map.getZoom(), 16), offset: [0, 80] });
}
}
}
function closeBottomSheet() {
bottomSheet.classList.remove('active');
if (map && map.getSource('assets') && lastSelectedFeatureId) {
try {
map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false });
} catch (e) {}
lastSelectedFeatureId = null;
}
}
btnSheetClose.addEventListener('click', closeBottomSheet);
@ -1247,6 +1376,10 @@
hideNoResultsInZoneAlert();
closeBottomSheet();
lastGeojson = { type: 'FeatureCollection', features: [] };
if (map && map.getSource('assets')) {
map.getSource('assets').setData(lastGeojson);
}
// Supprimer la persistance localStorage
try {

View file

@ -2,12 +2,12 @@ from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Polygon
from django.contrib.gis.geos import Polygon, MultiPolygon
from django.utils import translation
import json
from common.models import UserConfig, Role, Thematic, UserThematics
from assets.models import NatureTree, NatureLocation, NatureShrub, ParkingSpot
from assets.models import NatureTree, NatureLocation, NatureShrub, ParkingSpot, NatureGreenSurface
User = get_user_model()
@ -76,6 +76,15 @@ class MobileAssetDetailTests(TestCase):
geom=poly,
)
# Create test green surface asset (MultiPolygon)
poly_surf = Polygon(((648050, 670050), (648080, 670050), (648080, 670080), (648050, 670080), (648050, 670050)), srid=3812)
self.green_surface = NatureGreenSurface.objects.create(
code="S075-2138",
name_fr="Pelouse parc central",
status="active",
geom=MultiPolygon([poly_surf]),
)
def test_anonymous_user_redirected_to_login(self):
"""Anonymous user trying to access mobile asset detail is redirected to login."""
url = reverse("mobile:mobile_asset_detail_direct", args=["naturetree", self.tree.id])
@ -337,13 +346,14 @@ class MobileAssetDetailTests(TestCase):
codes = [item["code"] for item in data["results"]]
self.assertIn("ARB-TEST-001", codes)
self.assertIn("ARB-SHRUB-001", codes)
self.assertIn("S075-2138", codes)
# Check asset_type in nature results and GeoJSON properties
for item in data["results"]:
self.assertIn("asset_type", item)
self.assertIn(item["asset_type"], ["naturetree", "natureshrub"])
self.assertIn(item["asset_type"], ["naturetree", "natureshrub", "naturegreensurface"])
for feature in data["geojson"]["features"]:
self.assertIn("asset_type", feature["properties"])
self.assertIn(feature["properties"]["asset_type"], ["naturetree", "natureshrub"])
self.assertIn(feature["properties"]["asset_type"], ["naturetree", "natureshrub", "naturegreensurface"])
# 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"})
@ -425,3 +435,58 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "assets/naturetree_detail.html")
def test_search_linear_and_surfacic_geometries(self):
"""Surfacic and linear assets return their real Polygon/MultiPolygon geometry, bbox, and geom_type."""
user = User.objects.create_user(username="geo_search_user", password="password")
user_config = UserConfig.objects.create(user=user, is_intern=True)
user_config.roles.add(self.admin_role)
UserThematics.objects.create(
user_config=user_config,
thematic=self.thematic,
can_view_assets=True
)
UserThematics.objects.create(
user_config=user_config,
thematic=self.parking_thematic,
can_view_assets=True
)
self.client.login(username="geo_search_user", password="password")
search_url = reverse("mobile:mobile_assets_search")
# 1. Test nature search with green surface (MultiPolygon)
response = self.client.get(search_url, {"thematic": "nature", "q": "S075-2138"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["count"], 1)
result_item = data["results"][0]
self.assertEqual(result_item["code"], "S075-2138")
self.assertEqual(result_item["geom_type"], "MultiPolygon")
self.assertIsNotNone(result_item["bbox"])
self.assertEqual(len(result_item["bbox"]), 4)
self.assertIsNotNone(result_item["lat"])
self.assertIsNotNone(result_item["lon"])
feature = data["geojson"]["features"][0]
self.assertEqual(feature["geometry"]["type"], "MultiPolygon")
self.assertEqual(feature["properties"]["code"], "S075-2138")
self.assertEqual(feature["properties"]["geom_type"], "MultiPolygon")
self.assertEqual(feature["properties"]["bbox"], result_item["bbox"])
self.assertEqual(feature["id"], f"naturegreensurface_{self.green_surface.id}")
# 2. Test parking search with parking spot (Polygon)
response = self.client.get(search_url, {"thematic": "parking", "q": "PK-SPOT-001"})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["count"], 1)
spot_res = data["results"][0]
self.assertEqual(spot_res["geom_type"], "Polygon")
self.assertIsNotNone(spot_res["bbox"])
self.assertEqual(len(spot_res["bbox"]), 4)
spot_feat = data["geojson"]["features"][0]
self.assertEqual(spot_feat["geometry"]["type"], "Polygon")
self.assertEqual(spot_feat["properties"]["geom_type"], "Polygon")

View file

@ -1194,6 +1194,7 @@ def mobile_assets_search(request):
Supports proximity sorting if lat/lon parameters are provided.
"""
import math
import json
from urllib.parse import quote
from django.urls import reverse
from django.db.models import Q
@ -1418,22 +1419,55 @@ def mobile_assets_search(request):
for item in items:
lat = None
lon = None
geometry = None
geom_wgs84 = None
# 1. Vérifier si un GeoJSON complet existe déjà sur l'asset (via GeoSyncMixin)
raw_geojson = getattr(item, 'geojson', None)
if raw_geojson:
if isinstance(raw_geojson, dict):
geometry = raw_geojson
elif isinstance(raw_geojson, str):
try:
geometry = json.loads(raw_geojson)
except Exception:
geometry = None
# 2. Vérifier si l'asset a un champ geom pour obtenir la géométrie 4326 et son centroïde
if hasattr(item, 'geom') and item.geom:
try:
geom_wgs84 = item.geom.clone()
if geom_wgs84.srid != 4326:
geom_wgs84.transform(4326)
if not geometry:
geometry = json.loads(geom_wgs84.geojson)
except Exception:
geom_wgs84 = None
# 3. Récupérer les coordonnées ponctuelles lon/lat (pour tri de distance et centroïde)
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:
if (lon is None or lat is None) and geom_wgs84:
try:
g = item.geom.clone()
g.transform(4326)
centroid = g.centroid
lon = float(centroid.x)
lat = float(centroid.y)
c = geom_wgs84.centroid
lon = float(c.x)
lat = float(c.y)
except Exception:
pass
elif hasattr(item, 'location') and item.location:
if (lon is None or lat is None) and geometry and geometry.get('type') == 'Point' and geometry.get('coordinates'):
try:
lon = float(geometry['coordinates'][0])
lat = float(geometry['coordinates'][1])
except Exception:
pass
if (lon is None or lat is None) and hasattr(item, 'location') and item.location:
loc = item.location
if hasattr(loc, 'lat') and loc.lat and hasattr(loc, 'lon') and loc.lon:
try:
@ -1442,6 +1476,24 @@ def mobile_assets_search(request):
except (ValueError, TypeError):
pass
# 4. Fallback si geometry est absent mais qu'on a un point lon/lat
if not geometry and lon is not None and lat is not None:
geometry = {
'type': 'Point',
'coordinates': [lon, lat],
}
geom_type = geometry.get('type', 'Point') if geometry else 'Point'
# 5. Calcul de l'emprise bbox [min_lon, min_lat, max_lon, max_lat] pour zoom/cadrage
feature_bbox = None
if geom_wgs84 and geom_type != 'Point':
try:
ext = geom_wgs84.extent # (xmin, ymin, xmax, ymax)
feature_bbox = [float(ext[0]), float(ext[1]), float(ext[2]), float(ext[3])]
except Exception:
feature_bbox = 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:
try:
@ -1461,9 +1513,13 @@ def mobile_assets_search(request):
detail_url = reverse('mobile:mobile_asset_detail_direct', kwargs={'asset_model': model_key, 'asset_id': item.id})
spot_type = getattr(item, 'spot_type', None)
color_val = getattr(getattr(item, 'model', None), 'color', None) or getattr(item, 'color', None)
structure_type_val = getattr(item, 'structure_type', None)
feature_id = f"{model_key}_{item.id}"
res_item = {
'id': item.id,
'feature_id': feature_id,
'code': getattr(item, 'code', f'#{item.id}'),
'name': item_name,
'thematic': t_code,
@ -1473,6 +1529,10 @@ def mobile_assets_search(request):
'asset_type': model_key,
'spot_type': spot_type,
'element_type': element_type,
'geom_type': geom_type,
'color': color_val,
'structure_type': structure_type_val,
'bbox': feature_bbox,
'status': status_val,
'status_display': status_display,
'lat': lat,
@ -1482,16 +1542,15 @@ def mobile_assets_search(request):
}
results.append(res_item)
if lon is not None and lat is not None:
if geometry:
features.append({
'type': 'Feature',
'id': f"{model_key}_{item.id}",
'geometry': {
'type': 'Point',
'coordinates': [lon, lat],
},
'id': feature_id,
'geometry': geometry,
'bbox': feature_bbox,
'properties': {
'id': item.id,
'feature_id': feature_id,
'code': getattr(item, 'code', f'#{item.id}'),
'name': item_name,
'thematic': t_code,
@ -1501,10 +1560,16 @@ def mobile_assets_search(request):
'asset_type': model_key,
'spot_type': spot_type,
'element_type': element_type,
'geom_type': geom_type,
'color': color_val,
'structure_type': structure_type_val,
'bbox': feature_bbox,
'status': status_val,
'status_display': status_display,
'distance_m': distance_m,
'detail_url': detail_url,
'lon': lon,
'lat': lat,
}
})