diff --git a/streetup/assets/static/assets/inspection_map.js b/streetup/assets/static/assets/inspection_map.js index fe42dc2..2b75555 100644 --- a/streetup/assets/static/assets/inspection_map.js +++ b/streetup/assets/static/assets/inspection_map.js @@ -97,71 +97,200 @@ } map.on('load', function() { - // Source GeoJSON pour les assets + // Source GeoJSON pour les assets et localisations map.addSource('assets-source', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); - // Couche Polygones (ex: ParkingSpot polygon) + // 1. Couche Localisations (Zones GIEP, etc.) - EN DESSOUS DES ASSETS + map.addLayer({ + id: 'locations-polygons', + type: 'fill', + source: 'assets-source', + filter: [ + 'all', + ['==', ['coalesce', ['get', 'is_location'], false], true], + ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false] + ], + paint: { + 'fill-color': '#0094ff', + 'fill-opacity': 0.18 + } + }); + + map.addLayer({ + id: 'locations-lines', + type: 'line', + source: 'assets-source', + filter: [ + 'all', + ['==', ['coalesce', ['get', 'is_location'], false], true], + ['match', ['geometry-type'], ['LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'], true, false] + ], + paint: { + 'line-color': '#0094ff', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + }); + + // 2. Couche Polygones d'Assets (Noue, EVC, OED, TA, etc.) - AU-DESSUS DES LOCALISATIONS map.addLayer({ id: 'assets-polygons', type: 'fill', source: 'assets-source', - filter: ['==', '$type', 'Polygon'], + filter: [ + 'all', + ['!', ['coalesce', ['get', 'is_location'], false]], + ['!=', ['coalesce', ['get', 'model_name'], ''], 'parkingspot'], + ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false] + ], paint: { - 'fill-color': '#0d6efd', - 'fill-opacity': 0.35, - 'fill-outline-color': '#0d6efd' + 'fill-color': [ + 'match', ['coalesce', ['get', 'structure_type'], ''], + ['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation', 'oed'], '#8d6e63', + ['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle', 'evc'], '#009688', + ['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre', 'ta'], '#0277bd', + '#0288d1' + ], + 'fill-opacity': 0.55 } }); - // Couche Lignes pour le contour des polygones + // 3. Couche Polygones de Stationnement (Affichés à partir du Zoom 20 et plus) + map.addLayer({ + id: 'parking-polygons', + type: 'fill', + source: 'assets-source', + minzoom: 20, + filter: [ + 'all', + ['!', ['coalesce', ['get', 'is_location'], false]], + ['==', ['coalesce', ['get', 'model_name'], ''], 'parkingspot'], + ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false] + ], + paint: { + 'fill-color': '#0d6efd', + 'fill-opacity': 0.55, + 'fill-outline-color': '#0a58ca' + } + }); + + // 4. Couche Lignes d'Assets (LineStrings & contours de polygones d'assets) map.addLayer({ id: 'assets-lines', type: 'line', source: 'assets-source', - filter: ['==', '$type', 'Polygon'], + filter: [ + 'all', + ['!', ['coalesce', ['get', 'is_location'], false]], + ['match', ['geometry-type'], ['LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'], true, false] + ], paint: { - 'line-color': '#0d6efd', - 'line-width': 2 + 'line-color': [ + 'match', ['coalesce', ['get', 'structure_type'], ''], + ['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif', 'oed'], '#8d6e63', + ['noue', 'jardin de pluie', 'bande filtrante', 'evc'], '#009688', + ['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#0277bd', + '#0288d1' + ], + 'line-width': 2.5 } }); - // Couche Points (ex: Poteaux, Armoires) + // 5. Couche Points de Stationnement (1 seul point au centroïde pour Zoom < 20) + map.addLayer({ + id: 'parking-points', + type: 'circle', + source: 'assets-source', + maxzoom: 20, + filter: [ + 'all', + ['!', ['coalesce', ['get', 'is_location'], false]], + ['==', ['coalesce', ['get', 'model_name'], ''], 'parkingspot'], + ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false] + ], + paint: { + 'circle-radius': 6, + 'circle-color': '#0d6efd', + 'circle-stroke-width': 1.5, + 'circle-stroke-color': '#ffffff' + } + }); + + // 6. Couche Points d'Assets généraux (Poteaux, Avaloirs, Regards, etc.) map.addLayer({ id: 'assets-points', type: 'circle', source: 'assets-source', - filter: ['==', '$type', 'Point'], + filter: [ + 'all', + ['!', ['coalesce', ['get', 'is_location'], false]], + ['!=', ['coalesce', ['get', 'model_name'], ''], 'parkingspot'], + ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false] + ], paint: { - 'circle-radius': 8, - 'circle-color': '#0d6efd', + 'circle-radius': 7, + 'circle-color': [ + 'match', ['coalesce', ['get', 'structure_type'], ''], + ['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63', + ['jardin de pluie', 'noue', 'evc'], '#009688', + ['bassin orage', 'citerne', 'ta'], '#0277bd', + '#0288d1' + ], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } }); - // Clic sur un asset - map.on('click', ['assets-polygons', 'assets-points'], function(e) { - // Si un marker d'inspection est présent à cet endroit, ne pas ouvrir la modale directement + const interactiveAssetLayers = [ + 'assets-points', 'parking-points', 'assets-lines', 'assets-polygons', 'parking-polygons', 'locations-polygons' + ]; + + // Clic Priorisé sur un Asset / Localisation + map.on('click', interactiveAssetLayers, function(e) { if (map.getLayer('inspections-history-points')) { const inspFeatures = map.queryRenderedFeatures(e.point, { layers: ['inspections-history-points'] }); if (inspFeatures && inspFeatures.length > 0) { return; } } - if (e.features && e.features.length > 0) { - const feature = e.features[0]; - handleAssetClick(feature); - } + + const renderedFeatures = map.queryRenderedFeatures(e.point, { layers: interactiveAssetLayers }); + if (!renderedFeatures || renderedFeatures.length === 0) return; + + // Priorisation : Point (1) > Line (2) > Asset Polygon (3) > Location Polygon (4) + const priorityOrder = { + 'Point': 1, + 'LineString': 2, + 'MultiLineString': 2, + 'Polygon': 3, + 'MultiPolygon': 3 + }; + + renderedFeatures.sort((a, b) => { + const aIsLoc = a.properties && (a.properties.is_location === true || a.properties.is_location === 'true'); + const bIsLoc = b.properties && (b.properties.is_location === true || b.properties.is_location === 'true'); + + if (!aIsLoc && bIsLoc) return -1; + if (aIsLoc && !bIsLoc) return 1; + + const typeA = a.geometry ? a.geometry.type : ''; + const typeB = b.geometry ? b.geometry.type : ''; + const prioA = priorityOrder[typeA] || 9; + const prioB = priorityOrder[typeB] || 9; + return prioA - prioB; + }); + + handleAssetClick(renderedFeatures[0]); }); // Survol curseur - map.on('mouseenter', ['assets-polygons', 'assets-points'], function() { + map.on('mouseenter', interactiveAssetLayers, function() { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', ['assets-polygons', 'assets-points'], function() { + map.on('mouseleave', interactiveAssetLayers, function() { map.getCanvas().style.cursor = ''; }); @@ -173,8 +302,8 @@ // Placer la couche d'inspections EN DESSOUS des couches d'assets let beforeLayerId = undefined; - if (map.getLayer('assets-polygons')) { - beforeLayerId = 'assets-polygons'; + if (map.getLayer('locations-polygons')) { + beforeLayerId = 'locations-polygons'; } else if (map.getLayer('assets-points')) { beforeLayerId = 'assets-points'; } @@ -401,7 +530,7 @@ [sw.lng, sw.lat], [ne.lng, sw.lat], [ne.lng, ne.lat], [sw.lng, ne.lat], [sw.lng, sw.lat], - ]], + ]] }; } @@ -530,7 +659,9 @@ } function initFilterStorage() { - const savedThematic = localStorage.getItem(STORAGE_THEMATIC_KEY) || 'parking'; + const firstOpt = document.querySelector('#filter-thematic option:not([value=""])'); + const defaultThematic = firstOpt ? firstOpt.value : 'parking'; + const savedThematic = localStorage.getItem(STORAGE_THEMATIC_KEY) || defaultThematic; const savedCategory = localStorage.getItem(STORAGE_CATEGORY_KEY) || ''; updateUIElements(savedThematic, savedCategory); @@ -691,7 +822,7 @@ .catch(err => console.warn('Erreur chargement GeoJSON inspections:', err)); } - const MIN_ZOOM_FOR_GEOJSON = 16; + const MIN_ZOOM_FOR_GEOJSON = 13; function loadAssets() { if (!map) return; @@ -710,18 +841,21 @@ } const thematicSelect = document.getElementById('filter-thematic'); - const thematic = thematicSelect ? thematicSelect.value : ''; + let thematic = thematicSelect ? thematicSelect.value : ''; + if (!thematic) { + const firstOpt = document.querySelector('#filter-thematic option:not([value=""])'); + if (firstOpt) thematic = firstOpt.value; + } const categorySelect = document.getElementById('filter-category'); const categoryId = categorySelect ? categorySelect.value : ''; - let fetchUrl = '/assets/parking/assets/geojson/'; - if (thematic === 'parking') { - fetchUrl = '/assets/parking/assets/geojson/'; - } else if (thematic === 'trafficlights') { - fetchUrl = '/assets/trafficlights/assets/geojson/'; - } else if (thematic === 'publiclighting') { - fetchUrl = '/assets/publiclighting/assets/geojson/'; + let fetchUrls = [`/assets/${thematic || 'parking'}/assets/geojson/`]; + if (thematic === 'water' || thematic === 'giep') { + fetchUrls = [ + '/assets/water/assets/geojson/', + '/assets/water/locations/geojson/' + ]; } const payload = {}; @@ -734,45 +868,80 @@ payload.filter_geom = { type: 'Feature', geometry: bbox }; } - fetch(fetchUrl, { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken() - } - }) - .then(res => { - if (res.status === 413) { - if (warningAlert) warningAlert.classList.remove('d-none'); + const fetchPromises = fetchUrls.map(url => + fetch(url, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + } + }) + .then(res => (res.ok && res.status !== 413) ? res.json() : null) + .catch(err => { + console.warn('Erreur chargement assets depuis ' + url, err); return null; - } - if (!res.ok) { - if (warningAlert) warningAlert.classList.remove('d-none'); - return null; - } - return res.json(); - }) - .then(data => { - if (!data) return; + }) + ); - if (data.error) { - if (warningAlert) warningAlert.classList.remove('d-none'); - return; - } + Promise.all(fetchPromises).then(results => { + let combinedFeatures = []; + results.forEach(res => { + if (res && res.features && Array.isArray(res.features)) { + combinedFeatures = combinedFeatures.concat(res.features); + } + }); + + // Pour les parkings : générer 1 seul point centroïde pour l'affichage sous le zoom 20 + let processedFeatures = []; + combinedFeatures.forEach(f => { + processedFeatures.push(f); + if (f.properties && (f.properties.model_name === 'parkingspot' || f.properties.thematic === 'parking')) { + if (f.geometry && (f.geometry.type === 'Polygon' || f.geometry.type === 'MultiPolygon')) { + let lon = parseFloat(f.properties.lon); + let lat = parseFloat(f.properties.lat); + + if ((isNaN(lon) || isNaN(lat) || lon === 0 || lat === 0) && f.geometry.coordinates) { + try { + const ring = f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : (f.geometry.coordinates[0] ? f.geometry.coordinates[0][0] : null); + if (ring && ring.length > 0) { + let sumLon = 0, sumLat = 0; + ring.forEach(pt => { sumLon += pt[0]; sumLat += pt[1]; }); + lon = sumLon / ring.length; + lat = sumLat / ring.length; + } + } catch (e) { + console.warn('Erreur calcul centroïde parking:', e); + } + } + + if (!isNaN(lon) && !isNaN(lat) && lon !== 0 && lat !== 0) { + processedFeatures.push({ + type: 'Feature', + id: (f.id || f.properties.id) + '_point', + geometry: { + type: 'Point', + coordinates: [lon, lat] + }, + properties: Object.assign({}, f.properties, { is_centroid: true }) + }); + } + } + } + }); if (warningAlert) warningAlert.classList.add('d-none'); - if (data.features) { - loadedAssets = data.features; - const source = map.getSource('assets-source'); - if (source) { - source.setData(data); - } + loadedAssets = processedFeatures; + const source = map.getSource('assets-source'); + if (source) { + source.setData({ + type: 'FeatureCollection', + features: processedFeatures + }); } - }) - .catch(err => { - console.error('Error loading asset GeoJSON:', err); + }).catch(err => { + console.error('Erreur globale loadAssets:', err); if (warningAlert) warningAlert.classList.remove('d-none'); }); } @@ -992,20 +1161,30 @@ document.getElementById('insp_content_type_id').value = ctId || ''; document.getElementById('insp_object_id').value = objId; - document.getElementById('insp_target_type').textContent = props.asset_type_display || props.model_name || 'Asset'; - document.getElementById('insp_target_code').textContent = props.target_display || props.code || (objId ? ('#' + objId) : '-'); + let modelName = props.model_name || props.location_type || ''; + if (modelName === 'NatureRWIZ' || modelName === 'NatureRWIAsset' || modelName === 'naturerwiasset' || props.thematic === 'water' || props.location_type === 'NatureRWIZ') { + modelName = 'naturerwiz'; + } + + document.getElementById('insp_target_type').textContent = props.element_type || props.asset_type_display || props.model_name || 'Asset'; + document.getElementById('insp_target_code').textContent = props.code || props.target_display || (objId ? ('#' + objId) : '-'); document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-'; document.getElementById('insp_target_status').textContent = props.latest_result_status_display || props.status_display || props.status || '-'; const container = document.getElementById('assetSpecificFieldsContainer'); if (container) { container.innerHTML = ''; - const modelName = props.model_name || ''; const url = `/inspections/api/partial-form/?content_type_id=${encodeURIComponent(ctId || '')}&object_id=${encodeURIComponent(objId)}&model_name=${encodeURIComponent(modelName)}`; fetch(url) .then(res => res.text()) .then(html => { container.innerHTML = html; + Array.from(container.querySelectorAll('script')).forEach(oldScript => { + const newScript = document.createElement('script'); + Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)); + newScript.appendChild(document.createTextNode(oldScript.innerHTML)); + oldScript.parentNode.replaceChild(newScript, oldScript); + }); }) .catch(err => console.error('Error loading inspection fields partial:', err)); } diff --git a/streetup/assets/views/parking.py b/streetup/assets/views/parking.py index 695d90f..784983b 100644 --- a/streetup/assets/views/parking.py +++ b/streetup/assets/views/parking.py @@ -62,9 +62,10 @@ def parking_assets_geojson(request): status = data.get('status') filter_geom_raw = data.get("filter_geom") - if filter_geom_raw: + if filter_geom_raw and isinstance(filter_geom_raw, dict): try: - filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) + geom_json = filter_geom_raw.get('geometry', filter_geom_raw) if filter_geom_raw.get('type') == 'Feature' else filter_geom_raw + filter_geom = GEOSGeometry(json.dumps(geom_json), srid=4326) filter_geom.transform(3812) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) diff --git a/streetup/assets/views/water.py b/streetup/assets/views/water.py index d9c659b..60fb797 100644 --- a/streetup/assets/views/water.py +++ b/streetup/assets/views/water.py @@ -99,27 +99,32 @@ def water_locations_geojson(request): # Récupération et parsing de la géométrie filter_geom_raw = data.get("filter_geom") - if filter_geom_raw: + if filter_geom_raw and isinstance(filter_geom_raw, dict): try: - filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) - filter_geom.transform(3812) + geom_json = filter_geom_raw.get('geometry', filter_geom_raw) if filter_geom_raw.get('type') == 'Feature' else filter_geom_raw + filter_geom = GEOSGeometry(json.dumps(geom_json), srid=4326) + filter_geom_3812 = filter_geom.clone() + filter_geom_3812.transform(3812) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) else: - filter_geom = None + filter_geom_3812 = None else: - filter_geom = None + filter_geom_3812 = None rwiz_locations = NatureRWIZ.objects.all() - if filter_geom: - rwiz_locations = rwiz_locations.filter(geom__intersects=filter_geom) + if filter_geom_3812: + rwiz_locations = rwiz_locations.filter(geom__intersects=filter_geom_3812) # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search rwiz_locations = apply_search(rwiz_locations, data) + from django.contrib.contenttypes.models import ContentType + rwiz_ct_id = ContentType.objects.get_for_model(NatureRWIZ).id + from assets.views.core import get_element_type_label def obj_to_feature(obj): geometry = json.loads(obj.geojson) if obj.geojson else None @@ -128,6 +133,9 @@ def water_locations_geojson(request): "id": obj.id, "geometry": geometry, "properties": { + "object_id": obj.id, + "content_type_id": rwiz_ct_id, + "model_name": "naturerwiz", "code": obj.code, "name": obj.get_name(), "location_type": obj.__class__.__name__, @@ -168,22 +176,25 @@ def water_assets_geojson(request): # Récupération et parsing de la géométrie filter_geom_raw = data.get("filter_geom") - if filter_geom_raw: + if filter_geom_raw and isinstance(filter_geom_raw, dict): try: - filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) + geom_json = filter_geom_raw.get('geometry', filter_geom_raw) if filter_geom_raw.get('type') == 'Feature' else filter_geom_raw + filter_geom = GEOSGeometry(json.dumps(geom_json), srid=4326) + filter_geom_3812 = filter_geom.clone() + filter_geom_3812.transform(3812) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) else: - filter_geom = None + filter_geom_3812 = None else: section_id = request.GET.get('location') sections_ids = [section_id] if section_id else [] statuses = request.GET.getlist('asset_status') categories = request.GET.getlist('category', []) - filter_geom = None + filter_geom_3812 = None _search_q = (data.get('q') or '').strip() if request.method == 'POST' else '' - if not request.GET.get('unified') and not sections_ids and not categories and not filter_geom and not _search_q: + if not request.GET.get('unified') and not sections_ids and not categories and not filter_geom_3812 and not _search_q: return JsonResponse({ "type": "FeatureCollection", "features": [], @@ -197,9 +208,12 @@ def water_assets_geojson(request): if categories: naturerwiasset_qs = naturerwiasset_qs.filter(category__id__in=categories) - if filter_geom: - filter_geom_3812 = filter_geom.transform(3812, clone=True) - naturerwiasset_qs = naturerwiasset_qs.filter(geom__intersects=filter_geom_3812) + if filter_geom_3812: + from django.db.models import Q + naturerwiasset_qs = naturerwiasset_qs.filter( + Q(geom__intersects=filter_geom_3812) | + Q(geom__isnull=True, location__geom__intersects=filter_geom_3812) + ) if statuses: naturerwiasset_qs = naturerwiasset_qs.filter(status__in=statuses) @@ -214,7 +228,7 @@ def water_assets_geojson(request): if naturerwiasset_qs.count() > MAX_ASSET_FEATURES: return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413) - from django.db.models import FloatField, Value, CharField + from django.db.models import FloatField, Value, CharField, F from assets.views.core import get_element_type_label naturerwiasset_qs = (naturerwiasset_qs.annotate( @@ -222,8 +236,8 @@ def water_assets_geojson(request): category_name=F(f'category__name_{lang}'), model_name=Value(None, output_field=CharField()), color=Value(None, output_field=CharField()), - location_lon=Value(None, output_field=FloatField()), - location_lat=Value(None, output_field=FloatField()), + location_lon=F('location__lon'), + location_lat=F('location__lat'), ) .values( 'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat', 'surface_area', @@ -241,8 +255,8 @@ def water_assets_geojson(request): except (TypeError, ValueError, json.JSONDecodeError): geometry = None - lon = obj.get('lon') - lat = obj.get('lat') + lon = obj.get('lon') if obj.get('lon') is not None else obj.get('location_lon') + lat = obj.get('lat') if obj.get('lat') is not None else obj.get('location_lat') if geometry is None and lon is not None and lat is not None: geometry = { @@ -251,6 +265,8 @@ def water_assets_geojson(request): } return geometry, lon, lat + asset_ct_id = ContentType.objects.get_for_model(NatureRWIAsset).id + def obj_to_feature(obj, asset_type): geometry, lon, lat = build_geometry_and_coords(obj) detail_url = reverse('assets:water_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) @@ -259,6 +275,9 @@ def water_assets_geojson(request): "id": obj['id'], "geometry": geometry, "properties": { + "object_id": obj['id'], + "content_type_id": asset_ct_id, + "model_name": "naturerwiasset", "code": obj['code'], "name": obj['name'], "category": obj['category_name'], diff --git a/streetup/inspections/templates/inspections/quick_inspection_map.html b/streetup/inspections/templates/inspections/quick_inspection_map.html index 89edf4d..3d2300b 100644 --- a/streetup/inspections/templates/inspections/quick_inspection_map.html +++ b/streetup/inspections/templates/inspections/quick_inspection_map.html @@ -213,7 +213,7 @@ @@ -256,7 +256,7 @@