diff --git a/loko/mobile/templates/mobile/assets/assets_index_mobile.html b/loko/mobile/templates/mobile/assets/assets_index_mobile.html index dbd4b47..dad10a3 100644 --- a/loko/mobile/templates/mobile/assets/assets_index_mobile.html +++ b/loko/mobile/templates/mobile/assets/assets_index_mobile.html @@ -341,7 +341,267 @@ box-shadow: 0 2px 6px rgba(0,0,0,0.4); } - /* Marqueur spécifique poteau de signalisation (symbole desktop : socle circulaire + tige verticale) */ + /* Assemblage unifié poteau + panneaux (ancre à offset [0, 0] sur la base au sol) */ + .asset-map-assembly { + width: 28px; + height: 0px; + position: relative; + cursor: pointer; + pointer-events: none; + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 !important; + box-sizing: border-box; + z-index: 5; + transition: none !important; + } + /* Conteneur de mise à l'échelle dynamique des poteaux et panneaux selon le zoom */ + .asset-map-assembly .assembly-scaler { + width: 28px; + height: 0px; + position: absolute; + left: 0; + top: 0; + transform-origin: 50% 0; + transform: scale(var(--assembly-scale, 1)); + pointer-events: none; + transition: none !important; + } + .asset-map-assembly .pole-base { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #888888; + border: 1.5px solid #333333; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); + box-sizing: border-box; + position: absolute; + left: 50%; + top: 0; + transform: translate(-50%, -50%); + z-index: 2; + cursor: pointer; + pointer-events: auto; + transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; + } + .asset-map-assembly .pole-base::before { + content: ''; + position: absolute; + top: -8px; + bottom: -8px; + left: -8px; + right: -8px; + cursor: pointer; + } + .asset-map-assembly .pole-base:hover, + .asset-map-assembly .pole-base:active { + transform: translate(-50%, -50%) scale(1.3); + background-color: #ca8a04; + border-color: #854d0e; + box-shadow: 0 0 8px rgba(202, 138, 4, 0.6); + } + .asset-map-assembly.selected .pole-base, + .asset-map-assembly .pole-base.selected { + background-color: #fde047 !important; + border-color: #ca8a04 !important; + border-width: 2px !important; + transform: translate(-50%, -50%) scale(1.4); + box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.45), 0 0 12px #eab308 !important; + } + .asset-map-assembly .pole-stem { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 2.5px; + background-color: #555555; + border-left: 0.5px solid #222222; + border-right: 0.5px solid #222222; + border-top: 0.5px solid #222222; + box-sizing: border-box; + z-index: 1; + pointer-events: none; + transition: background-color 0.15s ease, box-shadow 0.15s ease; + } + .asset-map-assembly.selected .pole-stem { + background-color: #eab308 !important; + border-color: #ca8a04 !important; + box-shadow: 0 0 8px rgba(234, 179, 8, 0.7) !important; + } + .asset-map-assembly .assembly-panel-item { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + pointer-events: auto; + box-sizing: border-box; + z-index: 10; + } + .asset-map-assembly .assembly-panel-item::before { + content: ''; + position: absolute; + top: -8px; + bottom: -8px; + left: -8px; + right: -8px; + cursor: pointer; + } + .asset-map-assembly .assembly-panel-item img { + width: 20px; + height: 20px; + object-fit: contain; + display: block; + pointer-events: none; + filter: drop-shadow(0 1px 3px rgba(0,0,0,0.45)); + transition: transform 0.15s ease; + } + .asset-map-assembly .assembly-panel-item:hover img, + .asset-map-assembly .assembly-panel-item:active img { + transform: scale(1.25); + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6)); + } + .asset-map-assembly .assembly-panel-item i { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + background: #ffffff; + border: 1.5px solid #2563eb; + color: #2563eb; + font-size: 0.65rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + pointer-events: none; + transition: transform 0.15s ease; + } + .asset-map-assembly .assembly-panel-item:hover i, + .asset-map-assembly .assembly-panel-item:active i { + transform: scale(1.25); + box-shadow: 0 2px 6px rgba(0,0,0,0.4); + } + .asset-map-assembly .assembly-panel-item.selected img { + transform: scale(1.3); + filter: drop-shadow(0 0 5px #eab308) drop-shadow(0 0 10px #fde047) drop-shadow(0 2px 5px rgba(0,0,0,0.5)) !important; + } + .asset-map-assembly .assembly-panel-item.selected i { + border-color: #ca8a04 !important; + color: #854d0e !important; + transform: scale(1.3); + box-shadow: 0 0 8px rgba(234, 179, 8, 0.85); + } + .asset-map-assembly .assembly-panel-item.selected::after { + content: ''; + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border-radius: 4px; + border: 2px solid #eab308; + box-shadow: 0 0 8px rgba(234, 179, 8, 0.85); + pointer-events: none; + animation: marker-selected-pulse 1.8s infinite ease-in-out; + } + .asset-map-assembly .marker-count-badge { + position: absolute; + left: 50%; + top: 0; + transform: translate(6px, -14px); + } + + /* Marqueur spécifique panneau compact à faible/moyen zoom (< 16.5) */ + .asset-map-marker-compact { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + box-sizing: border-box; + transition: none !important; + } + .asset-map-marker-compact::before { + content: ''; + position: absolute; + top: -4px; + bottom: -4px; + left: -4px; + right: -4px; + cursor: pointer; + } + .asset-map-marker-compact img { + width: 20px; + height: 20px; + object-fit: contain; + display: block; + pointer-events: none; + filter: drop-shadow(0 1px 3px rgba(0,0,0,0.45)); + transition: transform 0.15s ease; + } + .asset-map-marker-compact:hover img, .asset-map-marker-compact:active img { + transform: scale(1.2); + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6)); + } + .asset-map-marker-compact i { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + background: #ffffff; + border: 1.5px solid #2563eb; + color: #2563eb; + font-size: 0.65rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + pointer-events: none; + transition: transform 0.15s ease; + } + .asset-map-marker-compact:hover i, .asset-map-marker-compact:active i { + transform: scale(1.2); + box-shadow: 0 2px 6px rgba(0,0,0,0.4); + } + .asset-map-marker-compact.selected { + z-index: 50 !important; + } + .asset-map-marker-compact.selected img { + transform: scale(1.25); + filter: drop-shadow(0 0 5px #eab308) drop-shadow(0 0 10px #fde047) drop-shadow(0 2px 5px rgba(0,0,0,0.5)) !important; + } + .asset-map-marker-compact.selected i { + border-color: #ca8a04; + color: #854d0e; + transform: scale(1.2); + box-shadow: 0 0 8px rgba(234, 179, 8, 0.85); + } + .asset-map-marker-compact.selected::after { + content: ''; + position: absolute; + top: -4px; + left: -4px; + right: -4px; + bottom: -4px; + border-radius: 50%; + border: 2px solid #eab308; + box-shadow: 0 0 8px rgba(234, 179, 8, 0.85); + pointer-events: none; + animation: marker-selected-pulse 1.8s infinite ease-in-out; + } + + /* Marqueur spécifique poteau de signalisation seul (socle circulaire + tige verticale) */ .asset-map-marker-pole { width: 24px; height: 24px; @@ -1101,6 +1361,26 @@ return null; } + // Mise à l'échelle dynamique des poteaux et panneaux selon le niveau de zoom + function updateAssemblyScale() { + if (!map) return; + const z = map.getZoom(); + let scale; + if (z >= 20.5) { + scale = 1.0; + } else if (z <= 12.5) { + scale = 0.25; + } else { + // La réduction commence plus tôt (dès zoom 20.5) et s'adapte régulièrement jusqu'à zoom 12.5 + scale = 0.25 + (z - 12.5) * (0.75 / 8.0); + } + scale = Math.round(scale * 100) / 100; + const container = map.getContainer ? map.getContainer() : document.getElementById('assets-map'); + if (container) { + container.style.setProperty('--assembly-scale', scale); + } + } + // 1. Initialisation de la carte MapLibre function initMap() { if (map) return; @@ -1122,6 +1402,10 @@ map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-left'); + // Écouter le zoom pour ajuster dynamiquement et immédiatement la taille des poteaux et panneaux + map.on('zoom', updateAssemblyScale); + updateAssemblyScale(); + // Couches vectorielles pour les éléments surfaciques (polygones) et linéaires (lignes) function setupMapLayers() { if (!map || !map.isStyleLoaded()) return; @@ -1217,6 +1501,8 @@ }, 200); }); + + map.on('load', () => { layersConfigured = false; setupMapLayers(); @@ -1546,11 +1832,11 @@ let isSameLocation = false; if (isSignRelated) { - // Pour les panneaux et poteaux, regrouper uniquement si même poteau - // ou si coordonnées strictement identiques (< 1m) pour panneaux sans pole_id + // Pour les panneaux et poteaux, regrouper si même poteau + // ou si coordonnées strictement identiques (< 1.5m) pour éléments sans poteau attribué if (samePole) { isSameLocation = true; - } else if (!asset.pole_id && !other.pole_id && !isAssetPole && !isOtherPole && asset.model_key === 'signpanel' && other.model_key === 'signpanel') { + } else if (!asset.pole_id && !other.pole_id) { const dLon = Math.abs(other.lon - asset.lon); const dLat = Math.abs(other.lat - asset.lat); isSameLocation = (dLon < 0.000015 && dLat < 0.000015); @@ -1584,61 +1870,52 @@ return 10; } - // 4. Créer les marqueurs pour chaque emplacement avec offset vertical pour les panneaux + // 4. Créer les marqueurs pour chaque emplacement + updateAssemblyScale(); locationGroups.forEach(group => { const signPanels = group.filter(a => a.model_key === 'signpanel'); const otherAssets = group.filter(a => a.model_key !== 'signpanel'); // CAS A : Emplacement avec un ou plusieurs panneaux de signalisation + // Rendu systématique en assemblage vertical unifié sans agrégation avec compteur if (signPanels.length > 0) { - // Trier pour identifier si un poteau ou support est présent const poleAsset = otherAssets.find(a => a.model_key === 'signpole' || (a.model_key && a.model_key.includes('pole')) || (a.thematic === 'sign' && a.model_key && a.model_key.includes('pole'))); const baseAsset = poleAsset || (otherAssets.length > 0 ? otherAssets[0] : null); - const hasBaseSupport = !!baseAsset; + + const refLon = baseAsset ? baseAsset.lon : signPanels[0].lon; + const refLat = baseAsset ? baseAsset.lat : signPanels[0].lat; + + // vertical_order = 1 est le panneau le plus bas (au-dessus du dégagement du sol) + // Les ordres supérieurs s'empilent vers le haut le long du mât + signPanels.sort((a, b) => { + const orderA = a.vertical_order != null ? Number(a.vertical_order) : (a.panel_vertical_order != null ? Number(a.panel_vertical_order) : 1); + const orderB = b.vertical_order != null ? Number(b.vertical_order) : (b.panel_vertical_order != null ? Number(b.panel_vertical_order) : 1); + return (orderA - orderB) || (Number(a.id) - Number(b.id)); + }); + const N = signPanels.length; - const highestPanelIndex = Math.max(0, N - 1); + const itemStep = 21; // Espacement vertical compact entre panneaux + const baseClearance = hasBaseSupport ? 16 : 0; // Dégagement harmonieux au-dessus du trottoir (tige visible ~12px) + const stemHeight = hasBaseSupport ? (baseClearance + (N - 1) * itemStep + 16) : 0; - // Dimensions compactes identiques à la vue desktop : - // Centre du panneau le plus bas à -14px au-dessus du sol/socle - // Incrément de 14px pour chaque panneau supplémentaire sur le mât - const stemHeight = hasBaseSupport ? (16 + (highestPanelIndex * 14)) : 0; - const topOffset = -(14 + (highestPanelIndex * 14)); + const el = document.createElement('div'); + el.className = 'asset-map-assembly'; + el.style.zIndex = '5'; - // 1. S'il y a un poteau ou support à la base, le positionner à l'offset [0, 0] - if (baseAsset) { - const symbology = (typeof AssetSymbology !== 'undefined') - ? AssetSymbology.getAssetSymbology(baseAsset) - : { color: THEMATIC_COLORS[baseAsset.thematic] || '#888888', iconClass: baseAsset.thematic_icon || 'bi-signpost-split' }; - const color = symbology.color || THEMATIC_COLORS[baseAsset.thematic] || '#888888'; - const iconHtml = (typeof AssetSymbology !== 'undefined') - ? AssetSymbology.renderIconHtml(symbology) - : ``; + const scaler = document.createElement('div'); + scaler.className = 'assembly-scaler'; + el.appendChild(scaler); - const isSignPole = baseAsset.model_key === 'signpole' || baseAsset.model_key.includes('pole') || (baseAsset.thematic === 'sign' && baseAsset.model_key.includes('pole')); + // 1. Socle / base circulaire du poteau (ancré exactement à Y=0 sur les coordonnées GPS) + if (hasBaseSupport) { + const baseDot = document.createElement('div'); + baseDot.className = 'pole-base'; + const baseFeatId = baseAsset.feature_id || (baseAsset.model_key && baseAsset.id ? `${baseAsset.model_key}_${baseAsset.id}` : null); + if (baseFeatId) baseDot.dataset.featureId = baseFeatId; + baseDot.title = baseAsset.name || baseAsset.code || 'Poteau'; - const el = document.createElement('div'); - if (isSignPole) { - el.className = 'asset-map-marker-pole'; - el.innerHTML = `
`; - el.style.zIndex = '2'; - } else { - el.className = 'asset-map-marker'; - el.style.borderColor = color; - el.style.color = color; - el.style.zIndex = '5'; - el.innerHTML = iconHtml; - } - - if (otherAssets.length > 1) { - const badge = document.createElement('span'); - badge.className = 'marker-count-badge'; - badge.textContent = otherAssets.length > 99 ? '99+' : otherAssets.length; - badge.title = `${otherAssets.length} équipements à la base`; - el.appendChild(badge); - } - - el.addEventListener('click', (e) => { + baseDot.addEventListener('click', (e) => { e.stopPropagation(); if (e.originalEvent) { e.originalEvent._handledByAssetLayer = true; @@ -1650,42 +1927,24 @@ } }); - const marker = new maplibregl.Marker({ element: el, offset: [0, 0] }) - .setLngLat([baseAsset.lon, baseAsset.lat]) - .addTo(map); - - marker._asset = baseAsset; - marker._group = group; - marker._element = el; - - const isBaseSelected = otherAssets.some(a => { - const featId = a.feature_id || (a.model_key && a.id ? `${a.model_key}_${a.id}` : null); - return lastSelectedFeatureId && featId === lastSelectedFeatureId; - }); - if (isBaseSelected) { - el.classList.add('selected'); + if (lastSelectedFeatureId && baseFeatId === lastSelectedFeatureId) { + baseDot.classList.add('selected'); el.style.zIndex = '50'; - selectedMarkerElement = el; + selectedMarkerElement = baseDot; } - activeMarkers.push(marker); + scaler.appendChild(baseDot); + + // Tige verticale montant vers le haut depuis la base + const stem = document.createElement('div'); + stem.className = 'pole-stem'; + stem.style.height = `${stemHeight}px`; + scaler.appendChild(stem); } - // 2. Calcul des offsets verticaux pour chaque panneau sur le poteau - // vertical_order = 1 est le panneau principal placé au SOMMET du mât. - // Les panneaux secondaires / panonceaux (vertical_order > 1) se succèdent vers le bas le long du mât. - signPanels.sort((a, b) => { - const orderA = a.vertical_order != null ? Number(a.vertical_order) : (a.panel_vertical_order != null ? Number(a.panel_vertical_order) : 1); - const orderB = b.vertical_order != null ? Number(b.vertical_order) : (b.panel_vertical_order != null ? Number(b.panel_vertical_order) : 1); - return (orderA - orderB) || (Number(a.id) - Number(b.id)); - }); - - // Coordonnées de référence partagées pour l'alignement parfait sur la tige - const refLon = baseAsset ? baseAsset.lon : signPanels[0].lon; - const refLat = baseAsset ? baseAsset.lat : signPanels[0].lat; - + // 2. Panneaux de signalisation empilés vers le haut signPanels.forEach((panel, index) => { - const panelOffset = topOffset + (index * 14); + const panelBottom = baseClearance + (index * itemStep); const symbology = (typeof AssetSymbology !== 'undefined') ? AssetSymbology.getAssetSymbology(panel) @@ -1694,12 +1953,16 @@ ? AssetSymbology.renderIconHtml(symbology) : ``; - const el = document.createElement('div'); - el.className = 'asset-map-marker-sign'; - el.innerHTML = iconHtml; - el.style.zIndex = String(10 + (N - index)); + const panelEl = document.createElement('div'); + panelEl.className = 'assembly-panel-item'; + panelEl.style.bottom = `${panelBottom}px`; + panelEl.style.zIndex = String(10 + index); + panelEl.innerHTML = iconHtml; - el.addEventListener('click', (e) => { + const featId = panel.feature_id || (panel.model_key && panel.id ? `${panel.model_key}_${panel.id}` : null); + if (featId) panelEl.dataset.featureId = featId; + + panelEl.addEventListener('click', (e) => { e.stopPropagation(); if (e.originalEvent) { e.originalEvent._handledByAssetLayer = true; @@ -1707,25 +1970,24 @@ openBottomSheet(panel, false, group); }); - const marker = new maplibregl.Marker({ element: el, offset: [0, panelOffset] }) - .setLngLat([refLon, refLat]) - .addTo(map); - - marker.setOffset([0, panelOffset]); - - marker._asset = panel; - marker._group = group; - marker._element = el; - - const featId = panel.feature_id || (panel.model_key && panel.id ? `${panel.model_key}_${panel.id}` : null); if (lastSelectedFeatureId && featId === lastSelectedFeatureId) { - el.classList.add('selected'); + panelEl.classList.add('selected'); el.style.zIndex = '50'; - selectedMarkerElement = el; + selectedMarkerElement = panelEl; } - activeMarkers.push(marker); + scaler.appendChild(panelEl); }); + + const marker = new maplibregl.Marker({ element: el, offset: [0, 0] }) + .setLngLat([refLon, refLat]) + .addTo(map); + + marker._asset = baseAsset || signPanels[0]; + marker._group = group; + marker._element = el; + + activeMarkers.push(marker); } else { // CAS B : Emplacement sans panneau (arbres, bancs, corbeilles, poteau seul, etc.) group.sort((a, b) => getDisplayPriority(b) - getDisplayPriority(a)); @@ -1743,17 +2005,63 @@ const el = document.createElement('div'); if (isSignPole) { - el.className = 'asset-map-marker-pole'; - el.innerHTML = '
'; + el.className = 'asset-map-assembly'; el.style.zIndex = '5'; + + const scaler = document.createElement('div'); + scaler.className = 'assembly-scaler'; + el.appendChild(scaler); + + const baseDot = document.createElement('div'); + baseDot.className = 'pole-base'; + const baseFeatId = primaryAsset.feature_id || (primaryAsset.model_key && primaryAsset.id ? `${primaryAsset.model_key}_${primaryAsset.id}` : null); + if (baseFeatId) baseDot.dataset.featureId = baseFeatId; + baseDot.title = primaryAsset.name || primaryAsset.code || 'Poteau'; + + baseDot.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.originalEvent) { + e.originalEvent._handledByAssetLayer = true; + } + if (group.length > 1) { + openMultiBottomSheet(group); + } else { + openBottomSheet(primaryAsset, false, group); + } + }); + + if (lastSelectedFeatureId && baseFeatId === lastSelectedFeatureId) { + baseDot.classList.add('selected'); + el.style.zIndex = '50'; + selectedMarkerElement = baseDot; + } + + scaler.appendChild(baseDot); + + const stem = document.createElement('div'); + stem.className = 'pole-stem'; + stem.style.height = '18px'; + scaler.appendChild(stem); } else { el.className = 'asset-map-marker'; el.style.borderColor = color; el.style.color = color; el.innerHTML = iconHtml; + + el.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.originalEvent) { + e.originalEvent._handledByAssetLayer = true; + } + if (group.length > 1) { + openMultiBottomSheet(group); + } else { + openBottomSheet(primaryAsset, false, group); + } + }); } - if (group.length > 1) { + if (!isSignPole && group.length > 1) { const badge = document.createElement('span'); badge.className = 'marker-count-badge'; badge.textContent = group.length > 99 ? '99+' : group.length; @@ -1761,18 +2069,6 @@ el.appendChild(badge); } - el.addEventListener('click', (e) => { - e.stopPropagation(); - if (e.originalEvent) { - e.originalEvent._handledByAssetLayer = true; - } - if (group.length > 1) { - openMultiBottomSheet(group); - } else { - openBottomSheet(primaryAsset, false, group); - } - }); - const marker = new maplibregl.Marker({ element: el, offset: [0, 0] }) .setLngLat([primaryAsset.lon, primaryAsset.lat]) .addTo(map); @@ -1955,6 +2251,8 @@ if (selectedMarkerElement) { selectedMarkerElement.classList.remove('selected'); selectedMarkerElement.style.zIndex = ''; + const parentAssembly = selectedMarkerElement.closest ? selectedMarkerElement.closest('.asset-map-assembly') : null; + if (parentAssembly) parentAssembly.style.zIndex = ''; selectedMarkerElement = null; } const currentFeatId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null); @@ -1970,9 +2268,17 @@ return m._group && m._group.some(g => String(g.feature_id) === String(currentFeatId) || (g.model_key === asset.model_key && String(g.id) === String(asset.id))); }); if (matchingMarker && matchingMarker._element) { - selectedMarkerElement = matchingMarker._element; - selectedMarkerElement.classList.add('selected'); - selectedMarkerElement.style.zIndex = '50'; + const subItem = matchingMarker._element.querySelector ? matchingMarker._element.querySelector(`[data-feature-id="${currentFeatId}"]`) : null; + if (subItem) { + selectedMarkerElement = subItem; + subItem.classList.add('selected'); + subItem.style.zIndex = '50'; + matchingMarker._element.style.zIndex = '50'; + } else { + selectedMarkerElement = matchingMarker._element; + selectedMarkerElement.classList.add('selected'); + selectedMarkerElement.style.zIndex = '50'; + } } } @@ -2033,6 +2339,8 @@ if (selectedMarkerElement) { selectedMarkerElement.classList.remove('selected'); selectedMarkerElement.style.zIndex = ''; + const parentAssembly = selectedMarkerElement.closest ? selectedMarkerElement.closest('.asset-map-assembly') : null; + if (parentAssembly) parentAssembly.style.zIndex = ''; selectedMarkerElement = null; } } @@ -2066,6 +2374,8 @@ if (selectedMarkerElement) { selectedMarkerElement.classList.remove('selected'); selectedMarkerElement.style.zIndex = ''; + const parentAssembly = selectedMarkerElement.closest ? selectedMarkerElement.closest('.asset-map-assembly') : null; + if (parentAssembly) parentAssembly.style.zIndex = ''; selectedMarkerElement = null; } if (multiContext && multiContext.length > 1) {