feat: add multi-asset bottom sheet and selection UI for overlapping map markers

This commit is contained in:
kdeterme 2026-09-13 14:30:58 +02:00
parent d33b49647b
commit bd8897499b

View file

@ -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 @@
<!-- VOLET INFÉRIEUR (Bottom Sheet Modal) pour la Carte -->
<div id="assetBottomSheet" class="bottom-sheet-preview">
<div class="bottom-sheet-drag-handle" id="bottomSheetCloseHandle"></div>
<button type="button" id="btnBackToMultiSheet" class="btn btn-sm btn-link text-decoration-none p-0 d-none text-primary mb-2">
<i class="bi bi-arrow-left me-1"></i><span id="btnBackToMultiSheetText">{% translate "Retour aux assets à cet endroit" %}</span>
</button>
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<div class="asset-icon-circle" id="sheetIconCircle">
@ -585,6 +645,27 @@
</a>
</div>
</div>
<!-- VOLET MULTI-ASSETS (quand plusieurs assets sont au même endroit) -->
<div id="multiAssetBottomSheet" class="bottom-sheet-preview">
<div class="bottom-sheet-drag-handle" id="multiSheetCloseHandle"></div>
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle bg-primary-subtle text-primary d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px; flex-shrink: 0;">
<i class="bi bi-layers-half fs-5"></i>
</div>
<div>
<div class="fw-bold text-dark fs-6" id="multiSheetTitle">{% translate "Plusieurs assets à cet endroit" %}</div>
<div class="small text-muted" id="multiSheetCount">2 {% translate "assets trouvés" %}</div>
</div>
</div>
<button type="button" class="btn-close" id="btnMultiSheetClose" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="text-secondary small mb-2">{% translate "Sélectionnez l'asset à consulter :" %}</div>
<div id="multiAssetListContainer" class="d-flex flex-column gap-1.5" style="max-height: 52vh; overflow-y: auto;">
<!-- Rempli dynamiquement via JS -->
</div>
</div>
{% 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)
: `<i class="bi ${symbology.iconClass || 'bi-pin-map'}"></i>`;
const statusClass = `badge-status badge-status-${asset.status || 'active'}`;
const distanceBadge = (asset.distance_m !== null && asset.distance_m !== undefined)
? `<span class="badge bg-primary-subtle text-primary ms-auto me-1 text-nowrap"><i class="bi bi-geo-alt me-1"></i>${formatDistance(asset.distance_m)}</span>`
: '';
const card = document.createElement('div');
card.className = 'multi-asset-card';
card.innerHTML = `
<div class="asset-icon-circle" style="background-color: ${color}18; color: ${color}; width: 36px; height: 36px; font-size: 1.1rem; flex-shrink: 0;">
${iconHtml}
</div>
<div class="flex-grow-1 text-truncate">
<div class="d-flex align-items-center gap-1.5 flex-wrap">
<span class="fw-bold text-dark text-truncate">${escapeHtml(asset.code)}</span>
<span class="${statusClass}">${escapeHtml(asset.status_display || asset.status || 'Actif')}</span>
</div>
<div class="small text-secondary text-truncate">${escapeHtml(asset.name || asset.element_type || asset.thematic_name || '')}</div>
</div>
${distanceBadge}
<i class="bi bi-chevron-right text-muted flex-shrink-0"></i>
`;
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() {