diff --git a/loko/sign/forms.py b/loko/sign/forms.py index 7eff401..2b1c9a9 100644 --- a/loko/sign/forms.py +++ b/loko/sign/forms.py @@ -8,17 +8,24 @@ class SignPoleForm(forms.ModelForm): class Meta: model = SignPole - fields = ['model', 'organization', 'orientation'] + fields = ['category', 'model', 'organization', 'orientation', 'support_type'] widgets = { 'category': forms.Select(attrs={'class': 'form-select'}), 'model': forms.Select(attrs={'class': 'form-select'}), 'organization': forms.Select(attrs={'class': 'form-select'}), + 'support_type': forms.Select(attrs={'class': 'form-select'}), 'orientation': forms.HiddenInput(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['organization'].required = True + if 'support_type' in self.fields: + self.fields['support_type'].required = False + self.fields['support_type'].initial = 'pole' + + def clean_support_type(self): + return self.cleaned_data.get('support_type') or 'pole' def save(self, commit=True): instance = super().save(commit=False) @@ -40,13 +47,14 @@ class SignPanelForm(forms.ModelForm): class Meta: model = SignPanel - fields = ['signpanel_type', 'model', 'organization', 'orientation', 'support_type', 'is_temporary', 'signpanel_text', 'signpanel_value'] + fields = ['signpanel_type', 'model', 'organization', 'orientation', 'support_type', 'support_model', 'is_temporary', 'signpanel_text', 'signpanel_value'] widgets = { 'signpanel_type': forms.Select(attrs={'class': 'form-select'}), 'model': forms.Select(attrs={'class': 'form-select'}), 'organization': forms.Select(attrs={'class': 'form-select'}), 'orientation': forms.NumberInput(attrs={'class': 'form-control'}), 'support_type': forms.Select(attrs={'class': 'form-select'}), + 'support_model': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour...)"}), 'signpanel_text': forms.TextInput(attrs={'class': 'form-control'}), 'signpanel_value': forms.NumberInput(attrs={'class': 'form-control'}), 'is_temporary': forms.CheckboxInput(attrs={'class': 'form-check-input'}), @@ -55,6 +63,11 @@ class SignPanelForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['organization'].required = True + if 'support_type' in self.fields: + self.fields['support_type'].required = False + self.fields['support_type'].initial = 'pole' + if 'support_model' in self.fields: + self.fields['support_model'].required = False def save(self, commit=True): instance = super().save(commit=False) diff --git a/loko/sign/migrations/0004_signpanel_support_model_signpanel_trafficlight_pole_and_more.py b/loko/sign/migrations/0004_signpanel_support_model_signpanel_trafficlight_pole_and_more.py new file mode 100644 index 0000000..30c18d1 --- /dev/null +++ b/loko/sign/migrations/0004_signpanel_support_model_signpanel_trafficlight_pole_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.7 on 2026-09-11 14:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0025_artworkfountain_external_reference_and_more'), + ('sign', '0003_signpanel_external_reference_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='signpanel', + name='support_model', + field=models.CharField(blank=True, help_text='Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, etc.)', max_length=255, null=True, verbose_name='Modèle du support'), + ), + migrations.AddField( + model_name='signpanel', + name='trafficlight_pole', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sign_app_panels', to='assets.trafficlightpole', verbose_name='Feu tricolore associé'), + ), + migrations.AddField( + model_name='signpole', + name='support_type', + field=models.CharField(choices=[('pole', 'Poteau'), ('lightingpole', "Poteau d'éclairage"), ('trafficlightpole', 'Poteau de signalisation lumineuse'), ('facade', 'Façade'), ('mast', 'Mât'), ('portico', 'Portique'), ('gallows', 'Potence'), ('hopper', 'Trémie'), ('other', 'Autre')], default='pole', max_length=50, verbose_name='Type de support'), + ), + migrations.AlterField( + model_name='signpanel', + name='signpanel_text', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/loko/sign/models.py b/loko/sign/models.py index fc09bbb..e4640e6 100644 --- a/loko/sign/models.py +++ b/loko/sign/models.py @@ -141,6 +141,19 @@ class SignPoleModel(AbstractAssetModel): verbose_name_plural = "Modèles de poteaux (Sign)" +SUPPORT_TYPE_CHOICES = ( + ("pole", "Poteau"), + ("lightingpole", "Poteau d'éclairage"), + ("trafficlightpole", "Poteau de signalisation lumineuse"), + ("facade", "Façade"), + ("mast", "Mât"), + ("portico", "Portique"), + ("gallows", "Potence"), + ("hopper", "Trémie"), + ("other", "Autre"), +) + + class SignPole(AbstractGeoAsset): category = models.ForeignKey( 'assets.AssetCategory', @@ -160,10 +173,21 @@ class SignPole(AbstractGeoAsset): default=90, help_text="Orientation en degrés (0-360)" ) + support_type = models.CharField( + max_length=50, + default="pole", + blank=True, + choices=SUPPORT_TYPE_CHOICES, + verbose_name="Type de support", + ) class Meta: indexes = [models.Index(fields=["code"])] + @property + def support_type_display(self): + return dict(SUPPORT_TYPE_CHOICES).get(self.support_type, self.support_type or "Poteau") + def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.geom and hasattr(self, 'sign_app_panels'): @@ -227,19 +251,6 @@ class SignPanelType(models.Model): return f"{self.code} - {self.get_name()}" -SUPPORT_TYPE_CHOICES = ( - ("pole", "Poteau"), - ("lightingpole", "Poteau d'éclairage"), - ("trafficlightpole", "Poteau de signalisation lumineuse"), - ("facade", "Façade"), - ("mast", "Mât"), - ("portico", "Portique"), - ("gallows", "Potence"), - ("hopper", "Trémie"), - ("other", "Autre"), -) - - class SignPanel(AbstractGeoAsset): category = models.ForeignKey( 'assets.AssetCategory', @@ -251,11 +262,26 @@ class SignPanel(AbstractGeoAsset): model = models.ForeignKey(SignPanelModel, on_delete=models.SET_NULL, null=True, blank=True) signpanel_type = models.ForeignKey(SignPanelType, on_delete=models.SET_NULL, null=True, blank=True) pole = models.ForeignKey(SignPole, related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True) + trafficlight_pole = models.ForeignKey( + "assets.TrafficLightPole", + related_name="sign_app_panels", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Feu tricolore associé", + ) vertical_order = models.IntegerField(null=True, blank=True) - signpanel_text = models.CharField(max_length=1000, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES) + signpanel_text = models.CharField(max_length=1000, null=True, blank=True) signpanel_value = models.FloatField(null=True, blank=True) orientation = models.IntegerField(null=True, blank=True, help_text="Orientation en degrés (0-360)") support_type = models.CharField(max_length=200, null=True, blank=True, choices=SUPPORT_TYPE_CHOICES) + support_model = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Modèle du support", + help_text="Modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, etc.)", + ) is_temporary = models.BooleanField(default=False) street = models.ForeignKey("common.AdrStreet", related_name="sign_app_panels", on_delete=models.SET_NULL, null=True, blank=True) organization = models.ForeignKey( @@ -267,22 +293,49 @@ class SignPanel(AbstractGeoAsset): class Meta: indexes = [models.Index(fields=["code"])] + @property + def effective_support_type(self): + if self.support_type: + return self.support_type + if self.pole: + return getattr(self.pole, 'support_type', 'pole') or 'pole' + if self.trafficlight_pole: + return 'trafficlightpole' + return 'pole' + + @property + def effective_support_type_display(self): + st = self.effective_support_type + return dict(SUPPORT_TYPE_CHOICES).get(st, st or "Poteau") + + @property + def support_model_display(self): + if self.support_model: + return self.support_model + if self.pole and self.pole.model: + return self.pole.model.get_name() + if self.trafficlight_pole and self.trafficlight_pole.model: + return self.trafficlight_pole.model.get_name() + return None + def clean(self): super().clean() - if self.pole and getattr(self.pole, 'geom', None): - self.geom = self.pole.geom - self.lon = self.pole.lon - self.lat = self.pole.lat - self.geojson = self.pole.geojson + support = self.pole or self.trafficlight_pole + if support and getattr(support, 'geom', None): + self.geom = support.geom + self.lon = support.lon + self.lat = support.lat + self.geojson = support.geojson if self.pole and self.pole.street and not self.street_id: self.street = self.pole.street def save(self, *args, **kwargs): - if self.pole and getattr(self.pole, 'geom', None): - self.geom = self.pole.geom - self.lon = self.pole.lon - self.lat = self.pole.lat - self.geojson = self.pole.geojson + support = self.pole or self.trafficlight_pole + if support and getattr(support, 'geom', None): + self.geom = support.geom + self.lon = support.lon + self.lat = support.lat + self.geojson = support.geojson if self.pole and self.pole.street and not self.street_id: self.street = self.pole.street super().save(*args, **kwargs) diff --git a/loko/sign/static/sign/sign.js b/loko/sign/static/sign/sign.js index 7f9f236..f555346 100644 --- a/loko/sign/static/sign/sign.js +++ b/loko/sign/static/sign/sign.js @@ -348,13 +348,13 @@ function initMapSources() { 17, 5, 22, 6 ], - 'circle-color': '#888888', - // 'circle-stroke-color': '#333333', - // 'circle-stroke-width': [ - // 'interpolate', ['linear'], ['zoom'], - // 8, 0, - // 13, 1 - // ], + 'circle-color': [ + 'case', + ['==', ['get', 'support_type'], 'trafficlightpole'], '#ef4444', + ['==', ['get', 'support_type'], 'lightingpole'], '#f59e0b', + ['==', ['get', 'support_type'], 'facade'], '#8b5cf6', + '#888888' + ], 'circle-opacity': 0.8 } }); @@ -387,7 +387,13 @@ function initMapSources() { source: 'sign-assets', filter: ['==', ['get', 'asset_type'], 'signpole'], layout: { - 'icon-image': 'sign-pole-vertical', + 'icon-image': [ + 'case', + ['==', ['get', 'support_type'], 'trafficlightpole'], 'sign-support-trafficlight', + ['==', ['get', 'support_type'], 'lightingpole'], 'sign-support-lightingpole', + ['==', ['get', 'support_type'], 'facade'], 'sign-support-facade', + 'sign-pole-vertical' + ], 'icon-anchor': 'top', 'icon-offset': ['literal', [0, -64]], // Adjust for pole height 'icon-allow-overlap': true, @@ -494,7 +500,12 @@ function setupMapInteractions() { const feature = e.features[0]; const coordinates = e.lngLat; - const description = `${feature.properties.code || gettext('Poteau')}`; + const supportType = feature.properties.support_type; + const supportLabel = feature.properties.support_type_label || (supportType === 'trafficlightpole' ? 'Feu tricolore' : (supportType === 'lightingpole' ? "Poteau d'éclairage" : (supportType === 'facade' ? 'Façade' : 'Poteau'))); + const supportIcon = supportType === 'trafficlightpole' ? '🚦 ' : ''; + const supportModelHtml = feature.properties.support_model ? `
${feature.properties.support_model}
` : ''; + const badgeClass = supportType === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary'; + const description = `${feature.properties.code || supportLabel} ${supportIcon}${supportLabel}${supportModelHtml}`; signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); }); @@ -509,7 +520,12 @@ function setupMapInteractions() { signMap.getCanvas().style.cursor = 'pointer'; const feature = e.features[0]; const coordinates = e.lngLat; - const description = `${feature.properties.code || gettext('Poteau')}`; + const supportType = feature.properties.support_type; + const supportLabel = feature.properties.support_type_label || (supportType === 'trafficlightpole' ? 'Feu tricolore' : (supportType === 'lightingpole' ? "Poteau d'éclairage" : (supportType === 'facade' ? 'Façade' : 'Poteau'))); + const supportIcon = supportType === 'trafficlightpole' ? '🚦 ' : ''; + const supportModelHtml = feature.properties.support_model ? `
${feature.properties.support_model}
` : ''; + const badgeClass = supportType === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary'; + const description = `${feature.properties.code || supportLabel} ${supportIcon}${supportLabel}${supportModelHtml}`; signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); }); @@ -525,7 +541,8 @@ function setupMapInteractions() { const feature = e.features[0]; const coordinates = e.lngLat; const panelType = feature.properties.panel_type || ''; - const description = `${panelType || feature.properties.code || gettext('Panneau')}`; + const supportModel = feature.properties.support_model ? `
${feature.properties.support_type_label || 'Support'}: ${feature.properties.support_model}
` : (feature.properties.support_type === 'trafficlightpole' ? '
🚦 Sur feu tricolore
' : ''); + const description = `${panelType || feature.properties.code || gettext('Panneau')}${supportModel}`; signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); }); @@ -542,7 +559,8 @@ function setupMapInteractions() { const feature = e.features[0]; const coordinates = e.lngLat; const panelType = feature.properties.panel_type || ''; - const description = `${panelType || feature.properties.code || gettext('Panneau')}`; + const supportModel = feature.properties.support_model ? `
${feature.properties.support_type_label || 'Support'}: ${feature.properties.support_model}
` : (feature.properties.support_type === 'trafficlightpole' ? '
🚦 Sur feu tricolore
' : ''); + const description = `${panelType || feature.properties.code || gettext('Panneau')}${supportModel}`; signPopup.setLngLat(coordinates).setHTML(description).addTo(signMap); }); @@ -897,11 +915,36 @@ function showAssetDetail(feature, assetType) { if (props.model) { detailHtml += `
-
${gettext('Modèle')}
+
${assetType === 'signpanel' ? gettext('Modèle du panneau') : gettext('Modèle')}
${props.model}
`; } + + // Support type (for both panels and poles) + if (props.support_type || props.support_type_label) { + const supportTypeLabel = props.support_type_label || props.support_type; + const supportIcon = props.support_type === 'trafficlightpole' ? '🚦' : (props.support_type === 'lightingpole' ? '💡' : (props.support_type === 'facade' ? '🏢' : '📍')); + const badgeClass = props.support_type === 'trafficlightpole' ? 'bg-danger text-white' : 'bg-secondary'; + detailHtml += ` +
+
${gettext('Type de support')}
+
+ ${supportIcon} ${supportTypeLabel} +
+
+ `; + } + + // Support model + if (props.support_model) { + detailHtml += ` +
+
${gettext('Modèle du support')}
+
${props.support_model}
+
+ `; + } if (props.status) { detailHtml += ` @@ -959,7 +1002,7 @@ function showAssetDetail(feature, assetType) { })(); // Edit Button for poles (after street view buttons) - if (assetType === 'signpole' && typeof userPerms !== 'undefined' && userPerms.change_signpole) { + if (assetType === 'signpole' && !props.is_standalone_support && typeof userPerms !== 'undefined' && userPerms.change_signpole) { let lat = props.lat; let lon = props.lon; if ((lat == null || lon == null) && feature.geometry && feature.geometry.type === 'Point') { @@ -977,12 +1020,22 @@ function showAssetDetail(feature, assetType) { `; } + if (props.is_standalone_support) { + detailHtml += ` +
+
+ ${gettext('Support autonome lié au panneau')} ${props.code || ''} +
+
+ `; + } + // For poles, list attached panels if (assetType === 'signpole') { try { const source = signMap.getSource('sign-assets'); const features = (source && source._data && source._data.features) ? source._data.features : []; - const panels = features.filter(f => f.properties && f.properties.asset_type === 'signpanel' && f.properties.pole_id === feature.id); + const panels = features.filter(f => f.properties && f.properties.asset_type === 'signpanel' && (f.properties.pole_id === feature.id || (props.is_standalone_support && f.properties.code === props.code))); // Sort panels by vertical order (if available) or id panels.sort((a, b) => { @@ -1201,6 +1254,182 @@ function loadRoadSignImages(map) { data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data }); + // Create traffic light support image (mast + 3-light traffic signal box) + const tlCanvas = document.createElement('canvas'); + tlCanvas.width = 24; + tlCanvas.height = 64; + const tlCtx = tlCanvas.getContext('2d'); + tlCtx.clearRect(0, 0, tlCanvas.width, tlCanvas.height); + + // Mast (poteau) from y = 28 to y = 64 + const tlMastWidth = 4; + const tlMastX = Math.floor((tlCanvas.width - tlMastWidth) / 2); // 10 + tlCtx.fillStyle = '#374151'; + tlCtx.strokeStyle = '#111827'; + tlCtx.lineWidth = 1; + tlCtx.fillRect(tlMastX, 28, tlMastWidth, 36); + tlCtx.strokeRect(tlMastX, 28, tlMastWidth, 36); + + // Base plate at bottom + tlCtx.fillStyle = '#1f2937'; + tlCtx.fillRect(tlMastX - 2, 61, tlMastWidth + 4, 3); + + // Traffic light casing (boîtier) + const boxW = 14; + const boxH = 28; + const boxX = Math.floor((tlCanvas.width - boxW) / 2); // 5 + const boxY = 2; + const radius = 3; + + // Outer casing + tlCtx.fillStyle = '#111827'; + tlCtx.strokeStyle = '#e5e7eb'; + tlCtx.lineWidth = 1.2; + tlCtx.beginPath(); + if (tlCtx.roundRect) { + tlCtx.roundRect(boxX, boxY, boxW, boxH, radius); + } else { + tlCtx.rect(boxX, boxY, boxW, boxH); + } + tlCtx.fill(); + tlCtx.stroke(); + + // 3 lights: Red, Amber, Green + const lightRadius = 3; + const lightCenterX = 12; + + // Red light (top) + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 7, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ef4444'; + tlCtx.fill(); + tlCtx.strokeStyle = '#7f1d1d'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 6, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + // Amber / Yellow light (middle) + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 16, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#f59e0b'; + tlCtx.fill(); + tlCtx.strokeStyle = '#78350f'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 15, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + // Green light (bottom) + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 25, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#10b981'; + tlCtx.fill(); + tlCtx.strokeStyle = '#064e3b'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 24, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + map.addImage('sign-support-trafficlight', { + width: tlCanvas.width, + height: tlCanvas.height, + data: tlCtx.getImageData(0, 0, tlCanvas.width, tlCanvas.height).data + }); + + // Create lighting pole support image (mast with curved arm and lamp head) + const lpCanvas = document.createElement('canvas'); + lpCanvas.width = 24; + lpCanvas.height = 64; + const lpCtx = lpCanvas.getContext('2d'); + lpCtx.clearRect(0, 0, lpCanvas.width, lpCanvas.height); + + const lpMastW = 4; + const lpMastX = Math.floor((lpCanvas.width - lpMastW) / 2); // 10 + lpCtx.fillStyle = '#4b5563'; + lpCtx.strokeStyle = '#1f2937'; + lpCtx.lineWidth = 1; + lpCtx.fillRect(lpMastX, 10, lpMastW, 54); + lpCtx.strokeRect(lpMastX, 10, lpMastW, 54); + + // Base plate + lpCtx.fillStyle = '#1f2937'; + lpCtx.fillRect(lpMastX - 2, 61, lpMastW + 4, 3); + + // Curved arm to the left + lpCtx.strokeStyle = '#4b5563'; + lpCtx.lineWidth = 2.5; + lpCtx.beginPath(); + lpCtx.moveTo(lpMastX + 2, 14); + lpCtx.quadraticCurveTo(lpMastX + 2, 3, 5, 5); + lpCtx.stroke(); + + // Luminaire / lamp head + lpCtx.fillStyle = '#fbbf24'; + lpCtx.strokeStyle = '#b45309'; + lpCtx.lineWidth = 1; + lpCtx.beginPath(); + if (lpCtx.ellipse) { + lpCtx.ellipse(5, 5, 4, 2.5, -0.3, 0, 2 * Math.PI); + } else { + lpCtx.arc(5, 5, 3, 0, 2 * Math.PI); + } + lpCtx.fill(); + lpCtx.stroke(); + + map.addImage('sign-support-lightingpole', { + width: lpCanvas.width, + height: lpCanvas.height, + data: lpCtx.getImageData(0, 0, lpCanvas.width, lpCanvas.height).data + }); + + // Create facade support image (wall bracket mount) + const fcCanvas = document.createElement('canvas'); + fcCanvas.width = 24; + fcCanvas.height = 64; + const fcCtx = fcCanvas.getContext('2d'); + fcCtx.clearRect(0, 0, fcCanvas.width, fcCanvas.height); + + // Wall texture on left side + fcCtx.fillStyle = '#64748b'; + fcCtx.strokeStyle = '#334155'; + fcCtx.lineWidth = 1; + fcCtx.fillRect(2, 16, 6, 32); + fcCtx.strokeRect(2, 16, 6, 32); + + // Mortar lines + fcCtx.strokeStyle = '#94a3b8'; + fcCtx.lineWidth = 0.8; + fcCtx.beginPath(); + fcCtx.moveTo(2, 24); fcCtx.lineTo(8, 24); + fcCtx.moveTo(2, 32); fcCtx.lineTo(8, 32); + fcCtx.moveTo(2, 40); fcCtx.lineTo(8, 40); + fcCtx.stroke(); + + // Horizontal bracket arm + fcCtx.strokeStyle = '#1e293b'; + fcCtx.lineWidth = 3; + fcCtx.beginPath(); + fcCtx.moveTo(8, 28); + fcCtx.lineTo(16, 28); + fcCtx.stroke(); + + // Vertical mounting sleeve + fcCtx.fillStyle = '#334155'; + fcCtx.fillRect(14, 18, 4, 20); + + map.addImage('sign-support-facade', { + width: fcCanvas.width, + height: fcCanvas.height, + data: fcCtx.getImageData(0, 0, fcCanvas.width, fcCanvas.height).data + }); + map.addImage('road-sign-images-loaded', { width: 1, height: 1, diff --git a/loko/sign/templates/sign/regulation_form.html b/loko/sign/templates/sign/regulation_form.html index 50ef8c9..5acd9f4 100644 --- a/loko/sign/templates/sign/regulation_form.html +++ b/loko/sign/templates/sign/regulation_form.html @@ -924,6 +924,165 @@ function loadRoadSignImages(map) { data: poleCtx.getImageData(0, 0, poleCanvas.width, poleCanvas.height).data }); + // Create traffic light support image (mast + 3-light traffic signal box) + const tlCanvas = document.createElement('canvas'); + tlCanvas.width = 24; + tlCanvas.height = 64; + const tlCtx = tlCanvas.getContext('2d'); + tlCtx.clearRect(0, 0, tlCanvas.width, tlCanvas.height); + + const tlMastWidth = 4; + const tlMastX = Math.floor((tlCanvas.width - tlMastWidth) / 2); + tlCtx.fillStyle = '#374151'; + tlCtx.strokeStyle = '#111827'; + tlCtx.lineWidth = 1; + tlCtx.fillRect(tlMastX, 28, tlMastWidth, 36); + tlCtx.strokeRect(tlMastX, 28, tlMastWidth, 36); + + tlCtx.fillStyle = '#1f2937'; + tlCtx.fillRect(tlMastX - 2, 61, tlMastWidth + 4, 3); + + const boxW = 14; + const boxH = 28; + const boxX = Math.floor((tlCanvas.width - boxW) / 2); + const boxY = 2; + const radius = 3; + + tlCtx.fillStyle = '#111827'; + tlCtx.strokeStyle = '#e5e7eb'; + tlCtx.lineWidth = 1.2; + tlCtx.beginPath(); + if (tlCtx.roundRect) { + tlCtx.roundRect(boxX, boxY, boxW, boxH, radius); + } else { + tlCtx.rect(boxX, boxY, boxW, boxH); + } + tlCtx.fill(); + tlCtx.stroke(); + + const lightRadius = 3; + const lightCenterX = 12; + + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 7, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ef4444'; + tlCtx.fill(); + tlCtx.strokeStyle = '#7f1d1d'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 6, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 16, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#f59e0b'; + tlCtx.fill(); + tlCtx.strokeStyle = '#78350f'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 15, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + tlCtx.beginPath(); + tlCtx.arc(lightCenterX, 25, lightRadius, 0, 2 * Math.PI); + tlCtx.fillStyle = '#10b981'; + tlCtx.fill(); + tlCtx.strokeStyle = '#064e3b'; + tlCtx.lineWidth = 0.8; + tlCtx.stroke(); + tlCtx.beginPath(); + tlCtx.arc(lightCenterX - 1, 24, 1, 0, 2 * Math.PI); + tlCtx.fillStyle = '#ffffff'; + tlCtx.fill(); + + map.addImage('sign-support-trafficlight', { + width: tlCanvas.width, + height: tlCanvas.height, + data: tlCtx.getImageData(0, 0, tlCanvas.width, tlCanvas.height).data + }); + + const lpCanvas = document.createElement('canvas'); + lpCanvas.width = 24; + lpCanvas.height = 64; + const lpCtx = lpCanvas.getContext('2d'); + lpCtx.clearRect(0, 0, lpCanvas.width, lpCanvas.height); + + const lpMastW = 4; + const lpMastX = Math.floor((lpCanvas.width - lpMastW) / 2); + lpCtx.fillStyle = '#4b5563'; + lpCtx.strokeStyle = '#1f2937'; + lpCtx.lineWidth = 1; + lpCtx.fillRect(lpMastX, 10, lpMastW, 54); + lpCtx.strokeRect(lpMastX, 10, lpMastW, 54); + + lpCtx.fillStyle = '#1f2937'; + lpCtx.fillRect(lpMastX - 2, 61, lpMastW + 4, 3); + + lpCtx.strokeStyle = '#4b5563'; + lpCtx.lineWidth = 2.5; + lpCtx.beginPath(); + lpCtx.moveTo(lpMastX + 2, 14); + lpCtx.quadraticCurveTo(lpMastX + 2, 3, 5, 5); + lpCtx.stroke(); + + lpCtx.fillStyle = '#fbbf24'; + lpCtx.strokeStyle = '#b45309'; + lpCtx.lineWidth = 1; + lpCtx.beginPath(); + if (lpCtx.ellipse) { + lpCtx.ellipse(5, 5, 4, 2.5, -0.3, 0, 2 * Math.PI); + } else { + lpCtx.arc(5, 5, 3, 0, 2 * Math.PI); + } + lpCtx.fill(); + lpCtx.stroke(); + + map.addImage('sign-support-lightingpole', { + width: lpCanvas.width, + height: lpCanvas.height, + data: lpCtx.getImageData(0, 0, lpCanvas.width, lpCanvas.height).data + }); + + const fcCanvas = document.createElement('canvas'); + fcCanvas.width = 24; + fcCanvas.height = 64; + const fcCtx = fcCanvas.getContext('2d'); + fcCtx.clearRect(0, 0, fcCanvas.width, fcCanvas.height); + + fcCtx.fillStyle = '#64748b'; + fcCtx.strokeStyle = '#334155'; + fcCtx.lineWidth = 1; + fcCtx.fillRect(2, 16, 6, 32); + fcCtx.strokeRect(2, 16, 6, 32); + + fcCtx.strokeStyle = '#94a3b8'; + fcCtx.lineWidth = 0.8; + fcCtx.beginPath(); + fcCtx.moveTo(2, 24); fcCtx.lineTo(8, 24); + fcCtx.moveTo(2, 32); fcCtx.lineTo(8, 32); + fcCtx.moveTo(2, 40); fcCtx.lineTo(8, 40); + fcCtx.stroke(); + + fcCtx.strokeStyle = '#1e293b'; + fcCtx.lineWidth = 3; + fcCtx.beginPath(); + fcCtx.moveTo(8, 28); + fcCtx.lineTo(16, 28); + fcCtx.stroke(); + + fcCtx.fillStyle = '#334155'; + fcCtx.fillRect(14, 18, 4, 20); + + map.addImage('sign-support-facade', { + width: fcCanvas.width, + height: fcCanvas.height, + data: fcCtx.getImageData(0, 0, fcCanvas.width, fcCanvas.height).data + }); + map.addImage('road-sign-images-loaded', { width: 1, height: 1, @@ -1217,7 +1376,13 @@ function initMapSources() { source: 'regulation-panels', filter: ['==', ['get', 'asset_type'], 'signpole'], layout: { - 'icon-image': 'sign-pole-vertical', + 'icon-image': [ + 'case', + ['==', ['get', 'support_type'], 'trafficlightpole'], 'sign-support-trafficlight', + ['==', ['get', 'support_type'], 'lightingpole'], 'sign-support-lightingpole', + ['==', ['get', 'support_type'], 'facade'], 'sign-support-facade', + 'sign-pole-vertical' + ], 'icon-anchor': 'top', 'icon-offset': ['literal', [0, -5]], 'icon-allow-overlap': true, diff --git a/loko/sign/templates/sign/sign_asset_detail.html b/loko/sign/templates/sign/sign_asset_detail.html index a4a9b54..97bf142 100644 --- a/loko/sign/templates/sign/sign_asset_detail.html +++ b/loko/sign/templates/sign/sign_asset_detail.html @@ -17,12 +17,32 @@ Code: {{ asset.code|default:"-" }}
Organisation: {{ asset.organization|default:"-" }}
{% if asset_model == 'signpole' %} - Modèle: {{ asset.model|default:"-" }}
- Hauteur: {{ asset.height|default:"-" }} m
- Couleur: {{ asset.color|default:"-" }}
+ {% translate "Type de support:" %} + {% if asset.support_type == 'trafficlightpole' %} + 🚦 {{ asset.support_type_display }} + {% else %} + {{ asset.support_type_display|default:"Poteau" }} + {% endif %}
+ {% translate "Modèle:" %} {{ asset.model|default:"-" }}
+ {% translate "Hauteur:" %} {{ asset.height|default:"-" }} m
+ {% translate "Couleur:" %} {{ asset.color|default:"-" }}
{% else %} - Type: {{ asset.signpanel_type.code }} - {{ asset.signpanel_type.get_name }}
- Texte: {{ asset.signpanel_text|default:"-" }}
+ {% translate "Type de panneau:" %} {{ asset.signpanel_type.code }} - {{ asset.signpanel_type.get_name }}
+ {% translate "Support:" %} + {% if asset.effective_support_type == 'trafficlightpole' %} + 🚦 {{ asset.effective_support_type_display }} + {% else %} + {{ asset.effective_support_type_display|default:"Poteau" }} + {% endif %} + {% if asset.pole %}({{ asset.pole.code }}){% endif %} +
+ {% if asset.support_model_display %} + {% translate "Modèle du support:" %} {{ asset.support_model_display }}
+ {% endif %} + {% if asset.model %} + {% translate "Modèle du panneau:" %} {{ asset.model }}
+ {% endif %} + {% translate "Texte:" %} {{ asset.signpanel_text|default:"-" }}
{% endif %} diff --git a/loko/sign/templates/sign/sign_panel_form.html b/loko/sign/templates/sign/sign_panel_form.html index 2c04778..ce70fe3 100644 --- a/loko/sign/templates/sign/sign_panel_form.html +++ b/loko/sign/templates/sign/sign_panel_form.html @@ -162,6 +162,12 @@ {{ form.support_type }} +
+ + {{ form.support_model }} +
{% translate "Précisez le modèle du support (ex: Poteau Acier 3m, Feu tricolore carrefour, Candélabre...)" %}
+
+
{{ form.orientation }} diff --git a/loko/sign/templates/sign/sign_pole_edit_form.html b/loko/sign/templates/sign/sign_pole_edit_form.html index 52dafa6..9d9eb25 100644 --- a/loko/sign/templates/sign/sign_pole_edit_form.html +++ b/loko/sign/templates/sign/sign_pole_edit_form.html @@ -43,6 +43,14 @@ {% endif %}
+
+ + {{ form.support_type }} + {% if form.support_type.errors %} +
{{ form.support_type.errors }}
+ {% endif %} +
+
{{ form.organization }} diff --git a/loko/sign/templates/sign/sign_pole_form.html b/loko/sign/templates/sign/sign_pole_form.html index e9c9cd6..feba3ea 100644 --- a/loko/sign/templates/sign/sign_pole_form.html +++ b/loko/sign/templates/sign/sign_pole_form.html @@ -43,6 +43,14 @@ {% endif %}
+
+ + {{ form.support_type }} + {% if form.support_type.errors %} +
{{ form.support_type.errors }}
+ {% endif %} +
+
{{ form.organization }} diff --git a/loko/sign/tests.py b/loko/sign/tests.py index a425d50..ff27995 100644 --- a/loko/sign/tests.py +++ b/loko/sign/tests.py @@ -1000,3 +1000,205 @@ class SignStreetAssignmentTests(TestCase): self.assertEqual(panel.vertical_order, 1) +class SupportRepresentationAndModelTests(TestCase): + def setUp(self): + from common.models import Municipality, AdrStreet, UserConfig, AppView + from sign.models import SignPanelType, SignPoleModel, SignPanelModel, SignPole, SignPanel, Organization + from assets.models import TrafficLightIntersection, TrafficLightPoleModel, TrafficLightPole + from django.contrib.auth import get_user_model + from django.contrib.gis.geos import Point + + User = get_user_model() + self.user = User.objects.create_user(username="support_user", password="password", is_superuser=True) + sign_view, _ = AppView.objects.get_or_create( + code="sign", + defaults={ + "name_fr": "Signalisation", + "name_nl": "Signalisatie", + "url_name": "sign:index", + } + ) + self.config = UserConfig.objects.create(user=self.user, esign_can_edit=True) + self.config.accessible_views.add(sign_view) + + self.muni = Municipality.objects.create(fid="1080", name_fr="Molenbeek", name_nl="Molenbeek") + self.adr_street = AdrStreet.objects.create( + fid="STR-100", + name_fr="Rue de Test", + name_nl="Teststraat", + municipality=self.muni, + ) + self.org = Organization.objects.create( + code="TEST_ORG", + name_fr="Test Organisation", + name_nl="Test Organisatie", + organization_type="region", + ) + self.pole_model = SignPoleModel.objects.create( + code="POL-MOD-1", + name_fr="Modèle Mât 4m", + ) + self.panel_model = SignPanelModel.objects.create( + code="PAN-MOD-1", + name_fr="Modèle Type B", + ) + self.panel_type = SignPanelType.objects.create( + code="B1", + name_fr="Céder le passage", + name_nl="Voorrang verlenen", + default_width=70.0, + default_height=70.0, + ) + self.tl_intersection = TrafficLightIntersection.objects.create( + code="TL-INT-01", + name_fr="Carrefour Test", + ) + self.tl_pole_model = TrafficLightPoleModel.objects.create( + code="TL-PM-01", + name_fr="Poteau Tricolore Acier 6m", + ) + self.tl_pole = TrafficLightPole.objects.create( + code="TL-P-01", + intersection=self.tl_intersection, + model=self.tl_pole_model, + geom=Point(145000, 172000, srid=3812), + lon=4.32, + lat=50.85, + ) + + def test_signpole_support_type(self): + """Test that SignPole supports support_type choices and defaults.""" + from sign.models import SignPole + pole = SignPole.objects.create( + code="P-TEST-01", + street=self.adr_street, + organization=self.org, + support_type="trafficlightpole", + model=self.pole_model, + ) + self.assertEqual(pole.support_type, "trafficlightpole") + self.assertEqual(pole.support_type_display, "Poteau de signalisation lumineuse") + + def test_signpanel_effective_support_and_model(self): + """Test effective_support_type and support_model_display on SignPanel.""" + from sign.models import SignPole, SignPanel + + # 1. Panel attached to pole with support_type + pole = SignPole.objects.create( + code="P-TEST-02", + street=self.adr_street, + organization=self.org, + support_type="lightingpole", + model=self.pole_model, + ) + panel1 = SignPanel.objects.create( + code="PANEL-01", + pole=pole, + model=self.panel_model, + signpanel_type=self.panel_type, + organization=self.org, + ) + self.assertEqual(panel1.effective_support_type, "lightingpole") + self.assertEqual(panel1.effective_support_type_display, "Poteau d'éclairage") + self.assertEqual(panel1.support_model_display, "Modèle Mât 4m") + + # 2. Panel attached directly to trafficlight_pole + panel2 = SignPanel.objects.create( + code="PANEL-02", + trafficlight_pole=self.tl_pole, + model=self.panel_model, + signpanel_type=self.panel_type, + organization=self.org, + ) + self.assertEqual(panel2.effective_support_type, "trafficlightpole") + self.assertEqual(panel2.effective_support_type_display, "Poteau de signalisation lumineuse") + self.assertEqual(panel2.support_model_display, "Poteau Tricolore Acier 6m") + + # 3. Standalone panel with support_type and custom support_model + panel3 = SignPanel.objects.create( + code="PANEL-03", + support_type="trafficlightpole", + support_model="Feu Swarco Carrefour", + model=self.panel_model, + signpanel_type=self.panel_type, + organization=self.org, + ) + self.assertEqual(panel3.effective_support_type, "trafficlightpole") + self.assertEqual(panel3.effective_support_type_display, "Poteau de signalisation lumineuse") + self.assertEqual(panel3.support_model_display, "Feu Swarco Carrefour") + + def test_sign_assets_geojson_includes_support_representation(self): + """Test that sign_assets_geojson includes support_type, support_model and standalone support features.""" + from django.contrib.gis.geos import Point + from sign.models import SignPanel + import json + + pt = Point(145000, 172000, srid=3812) + panel = SignPanel.objects.create( + code="PANEL-TL-01", + street=self.adr_street, + support_type="trafficlightpole", + support_model="Feu Siemens 3 Feux", + model=self.panel_model, + signpanel_type=self.panel_type, + organization=self.org, + geom=pt, + lon=4.32, + lat=50.85, + ) + + self.client.force_login(self.user) + response = self.client.post( + reverse('sign:sign_assets_geojson'), + data=json.dumps({'organization': [self.org.id]}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.json() + features = data.get('features', []) + self.assertTrue(len(features) >= 2) + + panel_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpanel'), None) + self.assertIsNotNone(panel_feat) + self.assertEqual(panel_feat['properties']['support_type'], 'trafficlightpole') + self.assertEqual(panel_feat['properties']['support_model'], 'Feu Siemens 3 Feux') + + support_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpole'), None) + self.assertIsNotNone(support_feat) + self.assertEqual(support_feat['properties']['support_type'], 'trafficlightpole') + self.assertEqual(support_feat['properties']['support_model'], 'Feu Siemens 3 Feux') + self.assertTrue(support_feat['properties'].get('is_standalone_support')) + + def test_sign_poles_overview_geojson_includes_standalone_trafficlight(self): + """Test sign_poles_overview_geojson includes standalone panel supports.""" + from django.contrib.gis.geos import Point + from sign.models import SignPanel + import json + + pt = Point(145000, 172000, srid=3812) + SignPanel.objects.create( + code="PANEL-TL-OV", + street=self.adr_street, + support_type="trafficlightpole", + support_model="Feu Carrefour 1", + organization=self.org, + geom=pt, + lon=4.32, + lat=50.85, + ) + + self.client.force_login(self.user) + response = self.client.post( + reverse('sign:sign_poles_overview_geojson'), + data=json.dumps({'organization': [self.org.id]}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.json() + features = data.get('features', []) + tl_support = next((f for f in features if f['properties'].get('support_type') == 'trafficlightpole'), None) + self.assertIsNotNone(tl_support) + self.assertEqual(tl_support['properties']['support_model'], 'Feu Carrefour 1') + self.assertTrue(tl_support['properties'].get('is_standalone_support')) + + diff --git a/loko/sign/views.py b/loko/sign/views.py index 8fd534c..177c6ab 100644 --- a/loko/sign/views.py +++ b/loko/sign/views.py @@ -31,6 +31,7 @@ from .models import ( RegulationMeasurePanel, SignPanelTypeArticle, RegulationArticleVoirie, + SUPPORT_TYPE_CHOICES, ) from .permissions import user_can_manage_regulations, user_can_edit_regulation, user_can_submit_regulation, user_can_edit_esign_assets, user_can_view_regulation from .forms import SignPoleForm, SignPanelForm @@ -532,12 +533,16 @@ def sign_poles_overview_geojson(request): # Use values() to fetch only the needed columns in a single query, # and build the detail URL without get_absolute_url() to avoid instantiation. - poles = qs.values('id', 'code', 'lon', 'lat') + poles = qs.values('id', 'code', 'lon', 'lat', 'support_type', 'model__name_fr') features = [] + seen_coords = set() for pole in poles.iterator(chunk_size=2000): lon, lat = pole['lon'], pole['lat'] + if lon is not None and lat is not None: + seen_coords.add((round(float(lon), 6), round(float(lat), 6))) geometry = {"type": "Point", "coordinates": [lon, lat]} if lon is not None and lat is not None else None + support_t = pole.get('support_type') or 'pole' features.append({ "type": "Feature", "id": pole['id'], @@ -545,12 +550,51 @@ def sign_poles_overview_geojson(request): "properties": { "asset_type": "signpole", "code": pole['code'], + "support_type": support_t, + "support_type_label": dict(SUPPORT_TYPE_CHOICES).get(support_t, support_t or "Poteau"), + "support_model": pole.get('model__name_fr') or None, "lat": lat, "lon": lon, "detail_url": f"/sign/assets/signpole/{pole['id']}/", } }) + # Include standalone panel supports (panels without pole, e.g. on traffic lights) + panel_supports = SignPanel.objects.filter( + Q(pole__isnull=True) & (Q(support_type__isnull=False) | Q(trafficlight_pole__isnull=False)) + ) + if organizations: + panel_supports = panel_supports.filter(organization_id__in=organizations) + if municipalities: + panel_supports = panel_supports.filter(street__municipality_id__in=municipalities) + if filter_geom_raw and filter_geom: + panel_supports = panel_supports.filter(geom__intersects=filter_geom) + + for p in panel_supports.values('id', 'code', 'lon', 'lat', 'support_type', 'trafficlight_pole_id', 'support_model'): + lon, lat = p['lon'], p['lat'] + if lon is not None and lat is not None: + coord_key = (round(float(lon), 6), round(float(lat), 6)) + if coord_key not in seen_coords: + seen_coords.add(coord_key) + st = p['support_type'] or ('trafficlightpole' if p.get('trafficlight_pole_id') else 'pole') + st_label = dict(SUPPORT_TYPE_CHOICES).get(st, st or "Poteau") + features.append({ + "type": "Feature", + "id": f"sp-{p['id']}", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": { + "asset_type": "signpole", + "is_standalone_support": True, + "code": p['code'], + "support_type": st, + "support_type_label": st_label, + "support_model": p.get('support_model') or None, + "lat": lat, + "lon": lon, + "detail_url": f"/sign/assets/signpanel/{p['id']}/", + } + }) + return JsonResponse({"type": "FeatureCollection", "features": features}) @@ -665,9 +709,13 @@ def sign_assets_geojson(request): else: pole_orientation_offset = [0, pole_offset] + support_t = getattr(obj, "support_type", "pole") or "pole" feature["properties"].update({ "pole_orientation_offset": pole_orientation_offset, "show_orientation_arrow": show_orientation_arrow, + "support_type": support_t, + "support_type_label": getattr(obj, "support_type_display", dict(SUPPORT_TYPE_CHOICES).get(support_t, "Poteau")), + "support_model": obj.model.get_name() if obj.model else None, }) if asset_type == "signpanel": panel_meta = panel_render_meta.get(obj.id, {}) @@ -703,6 +751,9 @@ def sign_assets_geojson(request): aspect_ratio = panel_meta.get("aspect_ratio", 1.0) feature["properties"].update({ "pole_id": getattr(obj, "pole_id", None), + "support_type": obj.effective_support_type, + "support_type_label": obj.effective_support_type_display, + "support_model": obj.support_model_display, "panel_text_can_render": can_render_text, "panel_text_image_id": sprite_id, "panel_hide_icon": can_render_text, @@ -725,7 +776,10 @@ def sign_assets_geojson(request): return feature_count, False signpole_qs = SignPole.objects.select_related('street', 'category', 'model').all() - signpanel_qs = SignPanel.objects.select_related('street', 'category', 'model', 'signpanel_type').all() + signpanel_qs = SignPanel.objects.select_related( + 'street', 'category', 'model', 'signpanel_type', 'pole', 'pole__model', + 'trafficlight_pole', 'trafficlight_pole__model' + ).all() if municipalities: signpole_qs = signpole_qs.filter(street__municipality_id__in=municipalities) @@ -819,6 +873,44 @@ def sign_assets_geojson(request): return JsonResponse({ "error": f"{_('Trop d\'objets à afficher')} (> {MAX_ASSET_FEATURES}). {_('Limitez votre sélection.')}" }, status=413) + + # Add standalone supports for panels without a SignPole (e.g. traffic lights, facades, lighting poles) + seen_standalone_coords = set() + for f in features: + if f.get("properties", {}).get("asset_type") == "signpole": + geom = f.get("geometry") + if geom and geom.get("type") == "Point": + coords = geom.get("coordinates") + if coords and len(coords) >= 2: + seen_standalone_coords.add((round(float(coords[0]), 6), round(float(coords[1]), 6))) + + for panel in signpanel_list: + if panel.pole_id is None and (panel.support_type or panel.trafficlight_pole_id): + if panel.lon is not None and panel.lat is not None: + coord_key = (round(float(panel.lon), 6), round(float(panel.lat), 6)) + if coord_key not in seen_standalone_coords: + seen_standalone_coords.add(coord_key) + features.append({ + "type": "Feature", + "id": f"sp-{panel.id}", + "geometry": json.loads(panel.geojson) if panel.geojson else {"type": "Point", "coordinates": [panel.lon, panel.lat]}, + "properties": { + "code": panel.code, + "name": panel.code, + "category": panel.category.get_name() if panel.category else None, + "model": None, + "support_type": panel.effective_support_type, + "support_type_label": panel.effective_support_type_display, + "support_model": panel.support_model_display, + "status": panel.status, + "lon": panel.lon, + "lat": panel.lat, + "asset_type": "signpole", + "is_standalone_support": True, + "detail_url": panel.get_absolute_url() if hasattr(panel, 'get_absolute_url') else None, + } + }) + features = sorted(features, key=lambda f: f["properties"]["code"]) geojson_data = {"type": "FeatureCollection", "features": features} return JsonResponse(geojson_data, safe=False) @@ -1289,7 +1381,10 @@ def regulation_panels_geojson(request, regulation_id: int): pass # Build queryset - panels_qs = SignPanel.objects.select_related('street', 'signpanel_type', 'pole', 'category', 'model') + panels_qs = SignPanel.objects.select_related( + 'street', 'signpanel_type', 'pole', 'pole__model', 'category', 'model', + 'trafficlight_pole', 'trafficlight_pole__model' + ) if filter_geom: panels_qs = panels_qs.filter(geom__intersects=filter_geom) @@ -1424,6 +1519,9 @@ def regulation_panels_geojson(request, regulation_id: int): "street": panel.street.get_name() if panel.street else "-", "status": panel.status, "asset_type": "signpanel", + "support_type": panel.effective_support_type, + "support_type_label": panel.effective_support_type_display, + "support_model": panel.support_model_display, "panel_icon_image": panel_icon_image, "panel_icon_image_scaled": panel_icon_image_scaled, "is_associated": is_associated, @@ -1446,7 +1544,54 @@ def regulation_panels_geojson(request, regulation_id: int): } } - features = [obj_to_feature(panel) for panel in panels_list] + # Also include support features (poles and standalone supports) so regulation map can render them + pole_features = [] + seen_pole_ids = set() + seen_support_coords = set() + for panel in panels_list: + if panel.pole_id and panel.pole_id not in seen_pole_ids: + seen_pole_ids.add(panel.pole_id) + pole = panel.pole + if pole and pole.lon is not None and pole.lat is not None: + pole_geom = json.loads(pole.geojson) if pole.geojson else {"type": "Point", "coordinates": [pole.lon, pole.lat]} + support_t = getattr(pole, 'support_type', 'pole') or 'pole' + support_m = pole.model.get_name() if pole.model else None + pole_features.append({ + "type": "Feature", + "id": f"pole-{pole.id}", + "geometry": pole_geom, + "properties": { + "asset_type": "signpole", + "code": pole.code, + "support_type": support_t, + "support_type_label": getattr(pole, 'support_type_display', dict(SUPPORT_TYPE_CHOICES).get(support_t, 'Poteau')), + "support_model": support_m, + "lon": pole.lon, + "lat": pole.lat, + } + }) + elif not panel.pole_id and (panel.support_type or panel.trafficlight_pole_id): + if panel.lon is not None and panel.lat is not None: + coord_key = (round(float(panel.lon), 6), round(float(panel.lat), 6)) + if coord_key not in seen_support_coords: + seen_support_coords.add(coord_key) + pole_features.append({ + "type": "Feature", + "id": f"support-panel-{panel.id}", + "geometry": json.loads(panel.geojson) if panel.geojson else {"type": "Point", "coordinates": [panel.lon, panel.lat]}, + "properties": { + "asset_type": "signpole", + "is_standalone_support": True, + "code": panel.code, + "support_type": panel.effective_support_type, + "support_type_label": panel.effective_support_type_display, + "support_model": panel.support_model_display, + "lon": panel.lon, + "lat": panel.lat, + } + }) + + features = [obj_to_feature(panel) for panel in panels_list] + pole_features geojson_data = { "type": "FeatureCollection", @@ -2323,6 +2468,18 @@ def sign_panel_create(request): panel.geojson = pole.geojson except SignPole.DoesNotExist: pass + elif panel.support_type == 'trafficlightpole' and lat and lon: + from assets.models import TrafficLightPole + try: + tl_pt = Point(float(lon), float(lat), srid=4326) + tl_pt.transform(3812) + tl_pole = TrafficLightPole.objects.filter(geom__dwithin=(tl_pt, 5)).first() + if tl_pole: + panel.trafficlight_pole = tl_pole + if not panel.support_model and tl_pole.model: + panel.support_model = tl_pole.model.get_name() + except Exception: + pass # If coordinates are provided and geom not yet set if lat and lon and not panel.geom: