feat: add sign panel map markers, count badges, and asset grouping logic

This commit is contained in:
kdeterme 2026-09-13 15:27:32 +02:00
parent 57976c71eb
commit e2be198802
3 changed files with 438 additions and 50 deletions

View file

@ -239,7 +239,7 @@
box-shadow: 0 0 10px rgba(13, 110, 253, 0.7);
}
/* Marqueur asset sur la carte */
/* Marqueur asset ponctuel standard */
.asset-map-marker {
width: 32px;
height: 32px;
@ -255,6 +255,7 @@
transition: none !important;
padding: 3px;
box-sizing: border-box;
position: relative;
}
.maplibregl-marker {
transition: none !important;
@ -282,6 +283,59 @@
box-shadow: 0 4px 10px rgba(0,0,0,0.4);
}
/* Marqueur spécifique panneau de signalisation (sans cercle bleu/blanc) */
.asset-map-marker-sign {
width: 32px;
height: 32px;
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-sign img {
width: 30px;
height: 30px;
object-fit: contain;
display: block;
pointer-events: none;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.45));
transition: transform 0.15s ease;
}
.asset-map-marker-sign:hover img, .asset-map-marker-sign:active img {
transform: scale(1.2);
filter: drop-shadow(0 3px 8px rgba(0,0,0,0.6));
}
/* Badge compteur pour assets multiples au même endroit */
.marker-count-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
padding: 0 3px;
border-radius: 9px;
background: #2563eb;
color: #ffffff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.45);
border: 1.5px solid #ffffff;
pointer-events: none;
z-index: 20;
}
/* Marqueur ponctuel sélectionné */
.asset-map-marker.selected {
z-index: 50 !important;
@ -304,6 +358,26 @@
pointer-events: none;
animation: marker-selected-pulse 1.8s infinite ease-in-out;
}
.asset-map-marker-sign.selected {
z-index: 50 !important;
}
.asset-map-marker-sign.selected img {
transform: scale(1.25);
filter: drop-shadow(0 0 5px #eab308) drop-shadow(0 0 10px #fde047) drop-shadow(0 3px 6px rgba(0,0,0,0.5)) !important;
}
.asset-map-marker-sign.selected::after {
content: '';
position: absolute;
top: -6px;
left: -6px;
right: -6px;
bottom: -6px;
border-radius: 50%;
border: 2.5px solid #eab308;
box-shadow: 0 0 10px 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);
@ -1306,17 +1380,204 @@
activeMarkers = [];
selectedMarkerElement = null;
(results || []).forEach(asset => {
if (asset.lon === null || asset.lat === null) return;
// 1. Filtrer les assets ponctuels valides
const punctualAssets = (results || []).filter(asset => {
if (asset.lon === null || asset.lat === null) return false;
return !asset.geom_type || asset.geom_type === 'Point' || asset.geom_type === 'MultiPoint';
});
// N'afficher un marqueur DOM HTML que pour les géométries ponctuelles
const isPunctual = !asset.geom_type || asset.geom_type === 'Point' || asset.geom_type === 'MultiPoint';
if (!isPunctual) return;
// 2. Regrouper les assets superposés aux mêmes coordonnées exactes ou sur le même poteau
const locationGroups = [];
const processedSet = new Set();
punctualAssets.forEach(asset => {
const key = String(asset.feature_id || `${asset.model_key}_${asset.id}`);
if (processedSet.has(key)) return;
const group = [asset];
processedSet.add(key);
punctualAssets.forEach(other => {
const otherKey = String(other.feature_id || `${other.model_key}_${other.id}`);
if (processedSet.has(otherKey)) return;
const samePole = (other.pole_id && asset.pole_id && String(other.pole_id) === String(asset.pole_id))
|| (asset.model_key && asset.model_key.includes('pole') && String(other.pole_id) === String(asset.id))
|| (other.model_key && other.model_key.includes('pole') && String(asset.pole_id) === String(other.id));
const dLon = Math.abs(other.lon - asset.lon);
const dLat = Math.abs(other.lat - asset.lat);
const isSameLocation = samePole || (dLon < 0.00003 && dLat < 0.00003);
if (isSameLocation) {
group.push(other);
processedSet.add(otherKey);
}
});
locationGroups.push(group);
});
// 3. Fonction pour déterminer l'asset prioritaire visuellement
function getDisplayPriority(a) {
// 1. Panneau avec visuel SVG/PNG : priorité maximale (100)
if (a.model_key === 'signpanel' && (a.svg_url || a.icon_url || a.panel_type)) return 100;
// 2. Autre panneau de signalisation : 90
if (a.model_key === 'signpanel') return 90;
// 3. Lanterne tricolore / luminaire : 80
if (a.model_key === 'trafficlightlantern' || a.model_key === 'publiclightinglightfixture') return 80;
// 4. Autre équipement non-poteau : 70
if (!a.model_key.includes('pole')) return 70;
// 5. Poteau / mât structural : priorité minimale (10)
return 10;
}
// 4. Créer les marqueurs pour chaque emplacement avec offset vertical pour les panneaux
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
if (signPanels.length > 0) {
// 1. S'il y a un poteau ou support à la base, le positionner à l'offset [0, 0]
if (otherAssets.length > 0) {
otherAssets.sort((a, b) => getDisplayPriority(b) - getDisplayPriority(a));
const baseAsset = otherAssets[0];
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';
? 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)
: `<i class="bi ${symbology.iconClass || 'bi-signpost-split'}"></i>`;
const el = document.createElement('div');
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) => {
e.stopPropagation();
if (e.originalEvent) {
e.originalEvent._handledByAssetLayer = true;
}
if (otherAssets.length > 1) {
openMultiBottomSheet(otherAssets);
} else {
openBottomSheet(baseAsset, false, group);
}
});
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');
el.style.zIndex = '50';
selectedMarkerElement = el;
}
activeMarkers.push(marker);
}
// 2. Calcul de l'offset vertical pour chaque panneau sur le poteau (identique à la version desktop)
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 hasBaseSupport = otherAssets.length > 0;
const MIN_PANEL_BASE_UP_OFFSET_PX = 36;
const DEFAULT_PANEL_HEIGHT_PX = 32;
const PANEL_STACK_GAP_PX = 2;
let cumulativeOffset = -MIN_PANEL_BASE_UP_OFFSET_PX;
let previousHeight = DEFAULT_PANEL_HEIGHT_PX;
signPanels.forEach((panel, index) => {
const renderHeight = DEFAULT_PANEL_HEIGHT_PX;
if (index === 0) {
if (hasBaseSupport) {
cumulativeOffset = -MIN_PANEL_BASE_UP_OFFSET_PX - Math.max(0, (renderHeight - DEFAULT_PANEL_HEIGHT_PX) / 2);
} else if (signPanels.length > 1) {
cumulativeOffset = 0 - Math.max(0, (renderHeight - DEFAULT_PANEL_HEIGHT_PX) / 2);
} else {
cumulativeOffset = 0;
}
} else {
cumulativeOffset -= (previousHeight + renderHeight) / 2 + PANEL_STACK_GAP_PX;
}
previousHeight = renderHeight;
const symbology = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getAssetSymbology(panel)
: { color: '#0022ff', iconClass: 'bi-sign-stop-fill' };
const iconHtml = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.renderIconHtml(symbology)
: `<i class="bi ${symbology.iconClass || 'bi-sign-stop-fill'}"></i>`;
const el = document.createElement('div');
el.className = 'asset-map-marker-sign';
el.innerHTML = iconHtml;
el.style.zIndex = String(10 + index);
el.addEventListener('click', (e) => {
e.stopPropagation();
if (e.originalEvent) {
e.originalEvent._handledByAssetLayer = true;
}
openBottomSheet(panel, false, group);
});
const marker = new maplibregl.Marker({ element: el, offset: [0, cumulativeOffset] })
.setLngLat([panel.lon, panel.lat])
.addTo(map);
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');
el.style.zIndex = '50';
selectedMarkerElement = el;
}
activeMarkers.push(marker);
});
} else {
// CAS B : Emplacement sans panneau (arbres, bancs, corbeilles, etc.)
group.sort((a, b) => getDisplayPriority(b) - getDisplayPriority(a));
const primaryAsset = group[0];
const symbology = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getAssetSymbology(primaryAsset)
: { color: THEMATIC_COLORS[primaryAsset.thematic] || '#2f3e8b', iconClass: primaryAsset.thematic_icon || 'bi-pin-map' };
const color = symbology.color || THEMATIC_COLORS[primaryAsset.thematic] || '#2f3e8b';
const iconHtml = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.renderIconHtml(symbology)
: `<i class="bi ${symbology.iconClass || 'bi-pin-map'}"></i>`;
@ -1327,34 +1588,46 @@
el.style.color = color;
el.innerHTML = iconHtml;
if (group.length > 1) {
const badge = document.createElement('span');
badge.className = 'marker-count-badge';
badge.textContent = group.length > 99 ? '99+' : group.length;
badge.title = `${group.length} assets à cet endroit`;
el.appendChild(badge);
}
el.addEventListener('click', (e) => {
e.stopPropagation();
if (e.originalEvent) {
e.originalEvent._handledByAssetLayer = true;
}
const matchingAssets = findAssetsAtMarker(asset);
if (matchingAssets.length > 1) {
openMultiBottomSheet(matchingAssets);
if (group.length > 1) {
openMultiBottomSheet(group);
} else {
openBottomSheet(asset);
openBottomSheet(primaryAsset, false, group);
}
});
const marker = new maplibregl.Marker({ element: el })
.setLngLat([asset.lon, asset.lat])
const marker = new maplibregl.Marker({ element: el, offset: [0, 0] })
.setLngLat([primaryAsset.lon, primaryAsset.lat])
.addTo(map);
marker._asset = asset;
marker._asset = primaryAsset;
marker._group = group;
marker._element = el;
const featId = asset.feature_id || (asset.model_key && asset.id ? `${asset.model_key}_${asset.id}` : null);
if (lastSelectedFeatureId && featId === lastSelectedFeatureId) {
const isGroupSelected = group.some(a => {
const featId = a.feature_id || (a.model_key && a.id ? `${a.model_key}_${a.id}` : null);
return lastSelectedFeatureId && featId === lastSelectedFeatureId;
});
if (isGroupSelected) {
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
@ -1475,7 +1748,7 @@
}
// 5b. Volet d'aperçu individuel (Bottom Sheet)
function openBottomSheet(asset, fromMulti = false) {
function openBottomSheet(asset, fromMulti = false, assetGroup = null) {
if (!asset) return;
// Fermer le volet multi-assets
@ -1483,12 +1756,14 @@
multiAssetBottomSheet.classList.remove('active');
}
// Bouton retour vers la liste multi si ouvert depuis une sélection multiple
// Bouton retour vers la liste multi si ouvert depuis une sélection multiple ou s'il y a d'autres assets sur le même support
const siblingGroup = (assetGroup && assetGroup.length > 1) ? assetGroup : ((lastMultiAssets && lastMultiAssets.length > 1) ? lastMultiAssets : null);
if (btnBackToMultiSheet) {
if (fromMulti && lastMultiAssets && lastMultiAssets.length > 1) {
if (siblingGroup && siblingGroup.length > 1) {
lastMultiAssets = siblingGroup;
btnBackToMultiSheet.classList.remove('d-none');
if (btnBackToMultiSheetText) {
btnBackToMultiSheetText.textContent = `{% translate "Retour aux" %} ${lastMultiAssets.length} {% translate "assets à cet endroit" %}`;
btnBackToMultiSheetText.textContent = `{% translate "Voir les" %} ${siblingGroup.length} {% translate "assets à cet endroit" %}`;
}
} else {
btnBackToMultiSheet.classList.add('d-none');
@ -1519,10 +1794,16 @@
}
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 && (
const matchingMarker = activeMarkers.find(m => {
if (!m._element) return false;
return m._asset && (
String(m._asset.feature_id) === String(currentFeatId) ||
(m._asset.model_key === asset.model_key && String(m._asset.id) === String(asset.id))
));
);
}) || activeMarkers.find(m => {
if (!m._element) return false;
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');

View file

@ -533,6 +533,7 @@ class MobileAssetDetailTests(TestCase):
street=street,
pole=pole,
signpanel_type=panel_type,
vertical_order=1,
status="active",
)
@ -549,6 +550,9 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(panel_item["panel_type"], "B1")
self.assertEqual(panel_item["svg_url"], "/static/assets/road_signs/2025/B1.svg")
self.assertEqual(panel_item["icon_url"], "/static/assets/road_signs/2025/B1.svg")
self.assertEqual(panel_item["pole_id"], pole.id)
self.assertEqual(panel_item["vertical_order"], 1)
self.assertEqual(panel_item["panel_vertical_order"], 1)
self.assertAlmostEqual(panel_item["lat"], 50.8466, places=4)
self.assertAlmostEqual(panel_item["lon"], 4.3528, places=4)
self.assertIn("B1", panel_item["name"])
@ -558,6 +562,9 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(feature["properties"]["panel_type"], "B1")
self.assertEqual(feature["properties"]["svg_url"], "/static/assets/road_signs/2025/B1.svg")
self.assertEqual(feature["properties"]["icon_url"], "/static/assets/road_signs/2025/B1.svg")
self.assertEqual(feature["properties"]["pole_id"], pole.id)
self.assertEqual(feature["properties"]["vertical_order"], 1)
self.assertEqual(feature["properties"]["panel_vertical_order"], 1)
self.assertAlmostEqual(feature["geometry"]["coordinates"][1], 50.8466, places=4)
self.assertAlmostEqual(feature["geometry"]["coordinates"][0], 4.3528, places=4)
@ -568,4 +575,63 @@ class MobileAssetDetailTests(TestCase):
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], panel.id)
def test_search_multiple_signpanels_on_same_pole_vertical_order(self):
"""Multiple panels on the same pole return distinct vertical_order and shared pole_id."""
user = User.objects.create_user(username="multi_panel_user", password="password")
user_config = UserConfig.objects.create(user=user, is_intern=True)
user_config.roles.add(self.admin_role)
UserThematics.objects.create(
user_config=user_config,
thematic=self.sign_thematic,
can_view_assets=True
)
from assets.models import SignPanel, SignPole, SignStreet, SignPanelType
from common.models import Municipality
mun, _ = Municipality.objects.get_or_create(name_fr="Bruxelles", defaults={"name_nl": "Brussel", "niscode": "21004"})
street = SignStreet.objects.create(name_fr="Rue Royale", municipality=mun)
pole = SignPole.objects.create(
code="POT-MULTI-01",
street=street,
lat=50.8500,
lon=4.3600,
status="active",
)
type_a, _ = SignPanelType.objects.get_or_create(code="A1a", defaults={"name_fr": "Virage dangereux à droite"})
type_c, _ = SignPanelType.objects.get_or_create(code="C1", defaults={"name_fr": "Sens interdit"})
panel1 = SignPanel.objects.create(
code="PAN-A1a-001",
street=street,
pole=pole,
signpanel_type=type_a,
vertical_order=1,
status="active",
)
panel2 = SignPanel.objects.create(
code="PAN-C1-002",
street=street,
pole=pole,
signpanel_type=type_c,
vertical_order=2,
status="active",
)
self.client.login(username="multi_panel_user", password="password")
search_url = reverse("mobile:mobile_assets_search")
response = self.client.get(search_url, {"thematic": "sign"})
self.assertEqual(response.status_code, 200)
data = response.json()
results_by_id = {r["id"]: r for r in data["results"] if r["model_key"] == "signpanel"}
self.assertIn(panel1.id, results_by_id)
self.assertIn(panel2.id, results_by_id)
self.assertEqual(results_by_id[panel1.id]["pole_id"], pole.id)
self.assertEqual(results_by_id[panel1.id]["vertical_order"], 1)
self.assertEqual(results_by_id[panel2.id]["pole_id"], pole.id)
self.assertEqual(results_by_id[panel2.id]["vertical_order"], 2)

View file

@ -1370,7 +1370,23 @@ def mobile_assets_search(request):
# Filtrage par boîte englobante (bbox / zoom de la carte)
if bbox_poly_3812:
if hasattr(model_class, 'geom'):
if model_key == 'signpanel':
panel_geo_q = Q(geom__bboverlaps=bbox_poly_3812)
panel_geo_q |= Q(geom__isnull=True, pole__geom__bboverlaps=bbox_poly_3812)
panel_geo_q |= Q(geom__isnull=True, trafficlight_pole__geom__bboverlaps=bbox_poly_3812)
panel_geo_q |= Q(geom__isnull=True, publiclighting_pole__geom__bboverlaps=bbox_poly_3812)
if min_lon is not None:
panel_geo_q |= Q(lon__gte=min_lon, lon__lte=max_lon, lat__gte=min_lat, lat__lte=max_lat)
panel_geo_q |= Q(lon__isnull=True, pole__lon__gte=min_lon, pole__lon__lte=max_lon, pole__lat__gte=min_lat, pole__lat__lte=max_lat)
qs = qs.filter(panel_geo_q)
elif model_key == 'trafficlightlantern':
tl_geo_q = Q(geom__bboverlaps=bbox_poly_3812)
tl_geo_q |= Q(geom__isnull=True, pole__geom__bboverlaps=bbox_poly_3812)
if min_lon is not None:
tl_geo_q |= Q(lon__gte=min_lon, lon__lte=max_lon, lat__gte=min_lat, lat__lte=max_lat)
tl_geo_q |= Q(lon__isnull=True, pole__lon__gte=min_lon, pole__lon__lte=max_lon, pole__lat__gte=min_lat, pole__lat__lte=max_lat)
qs = qs.filter(tl_geo_q)
elif hasattr(model_class, 'geom'):
qs = qs.filter(geom__bboverlaps=bbox_poly_3812)
elif min_lon is not None and hasattr(model_class, 'lon') and hasattr(model_class, 'lat'):
qs = qs.filter(lon__gte=min_lon, lon__lte=max_lon, lat__gte=min_lat, lat__lte=max_lat)
@ -1421,7 +1437,7 @@ def mobile_assets_search(request):
for model_key, model_class, qs in model_querysets:
if model_key == 'signpanel' and hasattr(model_class, 'signpanel_type'):
qs = qs.select_related('signpanel_type', 'model', 'pole')
qs = qs.select_related('signpanel_type', 'model', 'pole', 'trafficlight_pole', 'publiclighting_pole')
elif model_key == 'trafficlightlantern' and hasattr(model_class, 'model'):
qs = qs.select_related('model', 'pole')
items = list(qs)
@ -1572,6 +1588,21 @@ def mobile_assets_search(request):
structure_type_val = getattr(item, 'structure_type', None)
feature_id = f"{model_key}_{item.id}"
pole_id = None
vertical_order = None
panel_width = None
panel_height = None
if model_key == 'signpanel':
pole_id = getattr(item, 'pole_id', None) or getattr(item, 'trafficlight_pole_id', None) or getattr(item, 'publiclighting_pole_id', None)
vertical_order = getattr(item, 'vertical_order', None)
try:
panel_width = item.get_width()
panel_height = item.get_height()
except Exception:
pass
elif hasattr(item, 'pole_id'):
pole_id = getattr(item, 'pole_id', None)
res_item = {
'id': item.id,
'feature_id': feature_id,
@ -1597,6 +1628,11 @@ def mobile_assets_search(request):
'lon': lon,
'distance_m': distance_m,
'detail_url': detail_url,
'pole_id': pole_id,
'vertical_order': vertical_order,
'panel_vertical_order': vertical_order,
'width': panel_width,
'height': panel_height,
}
results.append(res_item)
@ -1631,6 +1667,11 @@ def mobile_assets_search(request):
'detail_url': detail_url,
'lon': lon,
'lat': lat,
'pole_id': pole_id,
'vertical_order': vertical_order,
'panel_vertical_order': vertical_order,
'width': panel_width,
'height': panel_height,
}
})