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: