feat: add multi-asset bottom sheet and selection UI for overlapping map markers
This commit is contained in:
parent
d33b49647b
commit
bd8897499b
1 changed files with 322 additions and 33 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue