diff --git a/loko/mobile/templates/mobile/assets/assets_index_mobile.html b/loko/mobile/templates/mobile/assets/assets_index_mobile.html index a29a3ab..a3d1d5f 100644 --- a/loko/mobile/templates/mobile/assets/assets_index_mobile.html +++ b/loko/mobile/templates/mobile/assets/assets_index_mobile.html @@ -282,6 +282,63 @@ box-shadow: 0 4px 10px rgba(0,0,0,0.4); } + /* Marqueur ponctuel sélectionné */ + .asset-map-marker.selected { + z-index: 50 !important; + border-color: #ca8a04 !important; + border-width: 3px !important; + background-color: #fef08a !important; + box-shadow: 0 0 0 4px rgba(254, 240, 138, 0.9), 0 4px 14px rgba(0, 0, 0, 0.45) !important; + position: relative; + } + .asset-map-marker.selected::after { + content: ''; + position: absolute; + top: -8px; + left: -8px; + right: -8px; + bottom: -8px; + border-radius: 50%; + border: 2.5px solid #eab308; + box-shadow: 0 0 12px rgba(234, 179, 8, 0.85); + pointer-events: none; + animation: marker-selected-pulse 1.8s infinite ease-in-out; + } + @keyframes marker-selected-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.95; + } + 50% { + transform: scale(1.4); + opacity: 0.25; + } + } + .asset-map-marker.selected i, + .asset-map-marker.selected img { + color: #854d0e !important; + transform: scale(1.15); + } + + /* Cartes d'assets multiples au même endroit */ + .multi-asset-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.85rem; + border: 1px solid #e2e8f0; + border-radius: 0.85rem; + background-color: #f8fafc; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; + } + .multi-asset-card:active { + background-color: #e2e8f0; + border-color: #cbd5e1; + transform: scale(0.98); + } + /* Volet inférieur (Bottom Sheet) pour prévisualisation */ .bottom-sheet-preview { position: fixed; @@ -552,6 +609,9 @@
+
@@ -585,6 +645,27 @@
+ + +
+
+
+
+
+ +
+
+
{% translate "Plusieurs assets à cet endroit" %}
+
2 {% translate "assets trouvés" %}
+
+
+ +
+
{% translate "Sélectionnez l'asset à consulter :" %}
+
+ +
+
{% endblock content %} {% block extra_scripts %} @@ -695,6 +776,18 @@ const sheetDetailLink = document.getElementById('sheetDetailLink'); const sheetRouteLink = document.getElementById('sheetRouteLink'); + // Éléments du volet Multi-Assets + const multiAssetBottomSheet = document.getElementById('multiAssetBottomSheet'); + const multiSheetCloseHandle = document.getElementById('multiSheetCloseHandle'); + const btnMultiSheetClose = document.getElementById('btnMultiSheetClose'); + const multiSheetCount = document.getElementById('multiSheetCount'); + const multiAssetListContainer = document.getElementById('multiAssetListContainer'); + const btnBackToMultiSheet = document.getElementById('btnBackToMultiSheet'); + const btnBackToMultiSheetText = document.getElementById('btnBackToMultiSheetText'); + + let lastMultiAssets = null; + let selectedMarkerElement = null; + // Couleurs par thématique (source unique : AssetSymbology) const THEMATIC_COLORS = (typeof AssetSymbology !== 'undefined') ? AssetSymbology.THEMATIC_COLORS @@ -851,15 +944,28 @@ 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; + if (e.originalEvent) { + e.originalEvent._handledByAssetLayer = true; + } + const bbox = [[e.point.x - 14, e.point.y - 14], [e.point.x + 14, e.point.y + 14]]; + const rendered = map.queryRenderedFeatures(bbox, { layers: ['assets-fill', 'assets-line'] }) || []; + const foundMap = new Map(); + + rendered.forEach(feat => { + const props = feat.properties || {}; + const featId = feat.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; + const key = String(asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : asset.code)); + if (!foundMap.has(key)) { + foundMap.set(key, asset); } - openBottomSheet(asset); + }); + + const assetsList = Array.from(foundMap.values()); + if (assetsList.length > 1) { + openMultiBottomSheet(assetsList); + } else if (assetsList.length === 1) { + openBottomSheet(assetsList[0]); } }); map.on('mouseenter', layerId, () => { @@ -876,6 +982,7 @@ map.on('click', (e) => { if (e.originalEvent && e.originalEvent._handledByAssetLayer) return; closeBottomSheet(); + closeMultiBottomSheet(); }); // Détection du déplacement ou zoom pour filtrage par zone visible (bbox) et synchronisation de la liste @@ -982,6 +1089,10 @@ assetsListContainer.innerHTML = ''; activeMarkers.forEach(m => m.remove()); activeMarkers = []; + selectedMarkerElement = null; + lastMultiAssets = null; + closeBottomSheet(); + closeMultiBottomSheet(); lastGeojson = { type: 'FeatureCollection', features: [] }; if (map && map.getSource('assets')) { map.getSource('assets').setData(lastGeojson); @@ -1168,6 +1279,7 @@ // Supprimer les anciens marqueurs activeMarkers.forEach(m => m.remove()); activeMarkers = []; + selectedMarkerElement = null; (results || []).forEach(asset => { if (asset.lon === null || asset.lat === null) return; @@ -1192,23 +1304,161 @@ el.addEventListener('click', (e) => { e.stopPropagation(); - openBottomSheet(asset); + if (e.originalEvent) { + e.originalEvent._handledByAssetLayer = true; + } + const matchingAssets = findAssetsAtMarker(asset); + if (matchingAssets.length > 1) { + openMultiBottomSheet(matchingAssets); + } else { + openBottomSheet(asset); + } }); const marker = new maplibregl.Marker({ element: el }) .setLngLat([asset.lon, asset.lat]) .addTo(map); + marker._asset = asset; + marker._element = el; + + const featId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null); + if (lastSelectedFeatureId && featId === lastSelectedFeatureId) { + el.classList.add('selected'); + el.style.zIndex = '50'; + selectedMarkerElement = el; + } + activeMarkers.push(marker); }); // Ne jamais modifier la visualisation (zoom ou pan) de manière automatique lors d'une action de filtre ou de recherche } - // 5. Volet d'aperçu inférieur (Bottom Sheet) - function openBottomSheet(asset) { + // Trouver tous les assets situés au même endroit ou à proximité immédiate (tap sur écran) + function findAssetsAtMarker(targetAsset) { + if (!map || !targetAsset) return [targetAsset]; + const targetPoint = (targetAsset.lon !== null && targetAsset.lat !== null) + ? map.project([targetAsset.lon, targetAsset.lat]) + : null; + const tolerancePx = 24; // Rayon de tolérance pour le tap tactile + const foundMap = new Map(); + const targetKey = String(targetAsset.feature_id || (targetAsset.model_key && targetAsset.id ? `${targetAsset.model_key}_${targetAsset.id}` : targetAsset.code)); + foundMap.set(targetKey, targetAsset); + + (lastResults || []).forEach(a => { + if (a.lon === null || a.lat === null) return; + const key = String(a.feature_id || (a.model_key && a.id ? `${a.model_key}_${a.id}` : a.code)); + if (foundMap.has(key)) return; + + // 1. Coordonnées identiques ou quasi-identiques + const dLon = Math.abs(a.lon - targetAsset.lon); + const dLat = Math.abs(a.lat - targetAsset.lat); + if (dLon < 0.00005 && dLat < 0.00005) { + foundMap.set(key, a); + return; + } + + // 2. Distance à l'écran (marqueurs superposés au zoom actuel) + if (targetPoint) { + const p = map.project([a.lon, a.lat]); + const dist = Math.hypot(p.x - targetPoint.x, p.y - targetPoint.y); + if (dist <= tolerancePx) { + foundMap.set(key, a); + } + } + }); + + return Array.from(foundMap.values()); + } + + // 5a. Volet Multi-Assets (quand plusieurs éléments se trouvent au même endroit) + function openMultiBottomSheet(assetsList) { + if (!assetsList || assetsList.length === 0) return; + lastMultiAssets = assetsList; + + // Fermer le bottom sheet simple + bottomSheet.classList.remove('active'); + + if (multiSheetCount) { + multiSheetCount.textContent = `${assetsList.length} {% translate "assets trouvés à cet endroit" %}`; + } + + multiAssetListContainer.innerHTML = ''; + assetsList.forEach(asset => { + const symbology = (typeof AssetSymbology !== 'undefined') + ? AssetSymbology.getAssetSymbology(asset) + : { color: THEMATIC_COLORS[asset.thematic] || '#2f3e8b', iconClass: asset.thematic_icon || 'bi-pin-map' }; + const color = symbology.color || THEMATIC_COLORS[asset.thematic] || '#2f3e8b'; + const iconHtml = (typeof AssetSymbology !== 'undefined') + ? AssetSymbology.renderIconHtml(symbology) + : ``; + + const statusClass = `badge-status badge-status-${asset.status || 'active'}`; + const distanceBadge = (asset.distance_m !== null && asset.distance_m !== undefined) + ? `${formatDistance(asset.distance_m)}` + : ''; + + const card = document.createElement('div'); + card.className = 'multi-asset-card'; + card.innerHTML = ` +
+ ${iconHtml} +
+
+
+ ${escapeHtml(asset.code)} + ${escapeHtml(asset.status_display || asset.status || 'Actif')} +
+
${escapeHtml(asset.name || asset.element_type || asset.thematic_name || '')}
+
+ ${distanceBadge} + + `; + + card.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + closeMultiBottomSheet(true); // Conserve le contexte multi pour le bouton retour + openBottomSheet(asset, true); // fromMulti = true + }); + + multiAssetListContainer.appendChild(card); + }); + + multiAssetBottomSheet.classList.add('active'); + } + + function closeMultiBottomSheet(keepMultiContext = false) { + if (multiAssetBottomSheet) { + multiAssetBottomSheet.classList.remove('active'); + } + if (!keepMultiContext) { + lastMultiAssets = null; + } + } + + // 5b. Volet d'aperçu individuel (Bottom Sheet) + function openBottomSheet(asset, fromMulti = false) { if (!asset) return; + // Fermer le volet multi-assets + if (multiAssetBottomSheet) { + multiAssetBottomSheet.classList.remove('active'); + } + + // Bouton retour vers la liste multi si ouvert depuis une sélection multiple + if (btnBackToMultiSheet) { + if (fromMulti && lastMultiAssets && lastMultiAssets.length > 1) { + btnBackToMultiSheet.classList.remove('d-none'); + if (btnBackToMultiSheetText) { + btnBackToMultiSheetText.textContent = `{% translate "Retour aux" %} ${lastMultiAssets.length} {% translate "assets à cet endroit" %}`; + } + } else { + btnBackToMultiSheet.classList.add('d-none'); + } + } + // Mettre en surbrillance sur la carte (pour surfaces polygonales et polylignes) if (map && map.getSource('assets')) { if (lastSelectedFeatureId) { @@ -1225,6 +1475,25 @@ } } + // Symbologie de sélection pour marqueur ponctuel + if (selectedMarkerElement) { + selectedMarkerElement.classList.remove('selected'); + selectedMarkerElement.style.zIndex = ''; + selectedMarkerElement = null; + } + const currentFeatId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null); + if (currentFeatId) { + const matchingMarker = activeMarkers.find(m => m._asset && ( + String(m._asset.feature_id) === String(currentFeatId) || + (m._asset.model_key === asset.model_key && String(m._asset.id) === String(asset.id)) + )); + if (matchingMarker && matchingMarker._element) { + selectedMarkerElement = matchingMarker._element; + selectedMarkerElement.classList.add('selected'); + selectedMarkerElement.style.zIndex = '50'; + } + } + sheetCode.textContent = asset.code || '-'; sheetName.textContent = asset.name || asset.element_type || ''; sheetThematicName.textContent = asset.thematic_name || ''; @@ -1263,41 +1532,61 @@ bottomSheet.classList.add('active'); - // 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] }); - } - } + // NOTE : Le centrage et le zoom ont été supprimés lors du tap pour préserver la vue choisie par l'utilisateur. } function closeBottomSheet() { bottomSheet.classList.remove('active'); + closeMultiBottomSheet(); if (map && map.getSource('assets') && lastSelectedFeatureId) { try { map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false }); } catch (e) {} lastSelectedFeatureId = null; } + if (selectedMarkerElement) { + selectedMarkerElement.classList.remove('selected'); + selectedMarkerElement.style.zIndex = ''; + selectedMarkerElement = null; + } } - btnSheetClose.addEventListener('click', closeBottomSheet); - bottomSheetCloseHandle.addEventListener('click', closeBottomSheet); + btnSheetClose.addEventListener('click', () => { + closeBottomSheet(); + lastMultiAssets = null; + }); + bottomSheetCloseHandle.addEventListener('click', () => { + closeBottomSheet(); + lastMultiAssets = null; + }); + + if (btnMultiSheetClose) { + btnMultiSheetClose.addEventListener('click', () => closeMultiBottomSheet()); + } + if (multiSheetCloseHandle) { + multiSheetCloseHandle.addEventListener('click', () => closeMultiBottomSheet()); + } + + if (btnBackToMultiSheet) { + btnBackToMultiSheet.addEventListener('click', () => { + const multiContext = lastMultiAssets; + bottomSheet.classList.remove('active'); + if (map && map.getSource('assets') && lastSelectedFeatureId) { + try { + map.setFeatureState({ source: 'assets', id: lastSelectedFeatureId }, { selected: false }); + } catch (e) {} + lastSelectedFeatureId = null; + } + if (selectedMarkerElement) { + selectedMarkerElement.classList.remove('selected'); + selectedMarkerElement.style.zIndex = ''; + selectedMarkerElement = null; + } + if (multiContext && multiContext.length > 1) { + openMultiBottomSheet(multiContext); + } + }); + } // 6. Gestion du GPS et localisation function locateUser() {