feat: add sign panel map markers, count badges, and asset grouping logic
This commit is contained in:
parent
57976c71eb
commit
e2be198802
3 changed files with 438 additions and 50 deletions
|
|
@ -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,55 +1380,254 @@
|
|||
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();
|
||||
|
||||
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>`;
|
||||
punctualAssets.forEach(asset => {
|
||||
const key = String(asset.feature_id || `${asset.model_key}_${asset.id}`);
|
||||
if (processedSet.has(key)) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'asset-map-marker';
|
||||
el.style.borderColor = color;
|
||||
el.style.color = color;
|
||||
el.innerHTML = iconHtml;
|
||||
const group = [asset];
|
||||
processedSet.add(key);
|
||||
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.originalEvent) {
|
||||
e.originalEvent._handledByAssetLayer = true;
|
||||
}
|
||||
const matchingAssets = findAssetsAtMarker(asset);
|
||||
if (matchingAssets.length > 1) {
|
||||
openMultiBottomSheet(matchingAssets);
|
||||
} else {
|
||||
openBottomSheet(asset);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([asset.lon, asset.lat])
|
||||
.addTo(map);
|
||||
locationGroups.push(group);
|
||||
});
|
||||
|
||||
marker._asset = asset;
|
||||
marker._element = el;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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(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>`;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'asset-map-marker';
|
||||
el.style.borderColor = color;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
|
||||
marker._asset = primaryAsset;
|
||||
marker._group = group;
|
||||
marker._element = el;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 && (
|
||||
String(m._asset.feature_id) === String(currentFeatId) ||
|
||||
(m._asset.model_key === asset.model_key && String(m._asset.id) === String(asset.id))
|
||||
));
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue