diff --git a/loko/assets/static/assets/asset_symbology.js b/loko/assets/static/assets/asset_symbology.js index ac59306..b967289 100644 --- a/loko/assets/static/assets/asset_symbology.js +++ b/loko/assets/static/assets/asset_symbology.js @@ -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 diff --git a/loko/assets/static/assets/unified_map_layers.js b/loko/assets/static/assets/unified_map_layers.js index a1f0b04..4a2cdf5 100644 --- a/loko/assets/static/assets/unified_map_layers.js +++ b/loko/assets/static/assets/unified_map_layers.js @@ -311,54 +311,39 @@ const _unified_locations_fill = { }; // ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ──── -const _unified_assets_fill = { - id: 'assets-fill', - type: 'fill', - source: 'assets', - filter: ['any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'] - ], - 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 +const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer) + ? AssetSymbology.getAssetsFillLayer('assets') + : { + id: 'assets-fill', + type: 'fill', + source: 'assets', + filter: ['any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'] + ], + paint: { + 'fill-color': [ + 'case', + ['==', ['get', 'thematic'], 'nature'], + ['coalesce', ['get', 'color'], '#2e7d32'], + ['==', ['get', 'thematic'], 'parking'], + (typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'), + THEMATIC_COLOR_EXPR ], - ['==', ['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) - ]), - 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-outline-color': [ - 'case', - ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b', - '#00000000' - ], - }, - minzoom: 8, -}; + 'fill-opacity': [ + 'case', + ['==', ['get', 'thematic'], 'parking'], 0.35, + 0.7 + ], + 'fill-antialias': false, + 'fill-outline-color': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b', + '#00000000' + ], + }, + minzoom: 8, + }; // ─── Layer 3 — location outline (line layer for polygon location borders) ───── const _unified_locations_outline = { @@ -698,37 +683,31 @@ const _unified_sign_poles_orientation = { }; // ─── Layer 3b — asset line (line geometries for linear assets like cables, ditches) ── -const _unified_assets_line = { - id: 'assets-line', - type: 'line', - source: 'assets', - filter: ['any', - ['==', ['geometry-type'], 'LineString'], - ['==', ['geometry-type'], 'MultiLineString'] - ], - 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' +const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsLineLayer) + ? AssetSymbology.getAssetsLineLayer('assets') + : { + id: 'assets-line', + type: 'line', + source: 'assets', + filter: ['any', + ['==', ['geometry-type'], 'LineString'], + ['==', ['geometry-type'], 'MultiLineString'] + ], + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'thematic'], 'nature'], '#2e7d32', + THEMATIC_COLOR_EXPR ], - ['==', ['get', 'thematic'], 'nature'], '#2e7d32', - THEMATIC_COLOR_EXPR - ], - 'line-width': [ - 'case', - ['boolean', ['feature-state', 'highlighted'], false], 5, - 3 - ], - 'line-opacity': 0.8, - }, - minzoom: 8, -}; + 'line-width': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], 5, + 3 + ], + 'line-opacity': 0.8, + }, + minzoom: 8, + }; // ─── Layer 4b — road assets circles (point roadway/sidewalk assets) ────────── const _unified_assets_point = { @@ -772,37 +751,33 @@ const _unified_assets_point = { }; // ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ─── -const _unified_assets_polygon_outline = { - id: 'assets-polygon-outline', - type: 'line', - source: 'assets', - filter: ['any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'] - ], - paint: { - 'line-color': [ - '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) - ]), - THEMATIC_COLOR_EXPR +const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsPolygonOutlineLayer) + ? AssetSymbology.getAssetsPolygonOutlineLayer('assets') + : { + id: 'assets-polygon-outline', + type: 'line', + source: 'assets', + filter: ['any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'] ], - - 'line-width': [ - 'case', - ['boolean', ['feature-state', 'highlighted'], false], 4, - 2 - ], - 'line-opacity': 0.9, - }, - minzoom: 8, -}; + paint: { + 'line-color': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b', + ['==', ['get', 'thematic'], 'parking'], + (typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'), + THEMATIC_COLOR_EXPR + ], + 'line-width': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], 4, + 2 + ], + 'line-opacity': 0.9, + }, + minzoom: 8, + }; // ─── Ordered layer list ─────────────────────────────────────────────────────── // MapLibre renders layers in insertion order, so the array below defines the diff --git a/loko/mobile/templates/mobile/assets/assets_index_mobile.html b/loko/mobile/templates/mobile/assets/assets_index_mobile.html index cb9a090..a29a3ab 100644 --- a/loko/mobile/templates/mobile/assets/assets_index_mobile.html +++ b/loko/mobile/templates/mobile/assets/assets_index_mobile.html @@ -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 { diff --git a/loko/mobile/tests_asset_mobile.py b/loko/mobile/tests_asset_mobile.py index 74143a1..db54bf8 100644 --- a/loko/mobile/tests_asset_mobile.py +++ b/loko/mobile/tests_asset_mobile.py @@ -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") + diff --git a/loko/mobile/views.py b/loko/mobile/views.py index 6fcbd08..3d1e25a 100644 --- a/loko/mobile/views.py +++ b/loko/mobile/views.py @@ -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, } })