feat(interventions): afficher et sélectionner les bornes ITS et caméras sur la carte

- Ajout de l'endpoint GeoJSON pour les traverses, plots de bornes et caméras ITS
- Affichage des lignes, points et étiquettes de code avec halo sur la carte d'ajout
- Activation/désactivation dynamique selon la catégorie de bornes rétractables
- Sélection directe de l'asset au clic sur la carte avec conservation des coordonnées cliquées
- Ajout des tests unitaires automatisés pour l'endpoint GeoJSON
This commit is contained in:
kdeterme 2026-10-12 11:53:58 +02:00
parent 611df933e5
commit ee73aa3912
6 changed files with 926 additions and 15 deletions

View file

@ -0,0 +1,311 @@
import json
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point, LineString
from django.urls import reverse
from common.models import Thematic, Role, UserConfig, UserThematics, UserContractAccess
from contracts.models import Contract
from assets.models.its import (
ITSLocation, ITSBollardSystem, ITSBollardPlot,
ITSBollardSystemAsset, ITSBollardSystemContract, ITSGeoAsset
)
User = get_user_model()
class ITSBollardSystemsGeojsonTests(TestCase):
def setUp(self):
self.thematic_its, _ = Thematic.objects.get_or_create(
code="its",
defaults={"name_fr": "Systèmes de transport intelligents", "name_nl": "ITS"}
)
self.role_admin, _ = Role.objects.get_or_create(name="admin")
# Superuser
self.superuser = User.objects.create_superuser(
username="admin_its",
email="admin_its@example.com",
password="password123"
)
UserConfig.objects.get_or_create(user=self.superuser, defaults={"is_intern": True})
# Internal user with ITS view access
self.user_internal = User.objects.create_user(
username="user_internal",
email="user_internal@example.com",
password="password123"
)
cfg_internal, _ = UserConfig.objects.get_or_create(
user=self.user_internal,
defaults={"is_intern": True, "limit_assets_to_contracts": False}
)
UserThematics.objects.create(
user_config=cfg_internal,
thematic=self.thematic_its,
can_view_assets=True,
can_edit_assets=False
)
# External user restricted to contracts
self.user_external = User.objects.create_user(
username="user_external",
email="user_external@example.com",
password="password123"
)
self.cfg_external, _ = UserConfig.objects.get_or_create(
user=self.user_external,
defaults={"is_intern": False, "limit_assets_to_contracts": True}
)
UserThematics.objects.create(
user_config=self.cfg_external,
thematic=self.thematic_its,
can_view_assets=True,
can_edit_assets=False
)
# Company & Contracts
from datetime import date
from contracts.models import Company
self.company = Company.objects.create(name="ITS Maintenance Corp")
self.contract_allowed = Contract.objects.create(
company=self.company,
contract_number="CTR-ITS-001",
start_date=date(2025, 1, 1),
end_date=date(2030, 1, 1),
description="Contrat Maintenance Bornes"
)
self.contract_allowed.thematics.add(self.thematic_its)
UserContractAccess.objects.create(
user_config=self.cfg_external,
contract=self.contract_allowed,
can_view_assets=True
)
self.contract_other = Contract.objects.create(
company=self.company,
contract_number="CTR-ITS-002",
start_date=date(2025, 1, 1),
end_date=date(2030, 1, 1),
description="Contrat Autre"
)
self.contract_other.thematics.add(self.thematic_its)
# Base Location
from django.contrib.gis.geos import Polygon, MultiPolygon
center = Point(4.3517, 50.8400, srid=4326)
center.transform(3812)
poly = center.buffer(20)
self.location = ITSLocation.objects.create(
code="LOC-001",
name_fr="Rue Haute - Entrée Piétonne",
lon=4.3517,
lat=50.8400,
geom=MultiPolygon([poly], srid=3812)
)
# Bollard System 1 (with geometry LineString)
# 3812 coords approx around Brussels
line_3812 = LineString((148000, 170000), (148020, 170010), srid=3812)
self.system1 = ITSBollardSystem.objects.create(
code="BS-001",
name_fr="Traverse Bornes Rue Haute",
status="active",
width=6.5,
direction="bidirectional",
geom=line_3812,
lon=4.3517,
lat=50.8400,
location=self.location
)
ITSBollardSystemContract.objects.create(
system=self.system1,
contract=self.contract_allowed,
status="active"
)
# Bollard Plots for System 1
plot_pt1 = Point(148005, 170002, srid=3812)
self.plot1 = ITSBollardPlot.objects.create(
code="PL-001",
name_fr="Borne 1 (Gauche)",
system=self.system1,
position=1,
bollard_type="automatic",
status="active",
geom=plot_pt1,
lon=4.35172,
lat=50.84002
)
plot_pt2 = Point(148015, 170007, srid=3812)
self.plot2 = ITSBollardPlot.objects.create(
code="PL-002",
name_fr="Borne 2 (Droite)",
system=self.system1,
position=2,
bollard_type="automatic",
status="active",
geom=plot_pt2,
lon=4.35178,
lat=50.84005
)
# Linked Camera for System 1
from assets.models.its import ITSAssetModel
self.cam_model = ITSAssetModel.objects.create(
code="MOD-CAM-01",
name_fr="Caméra Axis LAPI"
)
cam_pt = Point(148010, 170015, srid=3812)
self.camera = ITSGeoAsset.objects.create(
model=self.cam_model,
code="CAM-001",
name_fr="Caméra LAPI Entrée",
status="active",
geom=cam_pt,
lon=4.35175,
lat=50.84010,
location=self.location
)
self.link_cam = ITSBollardSystemAsset.objects.create(
bollard_system=self.system1,
asset=self.camera,
role=ITSBollardSystemAsset.AssetRole.CAMERA_ENTRY,
comment="Caméra fixée sur candélabre"
)
# Bollard System 2 (Linked only to contract_other)
line2_3812 = LineString((149000, 171000), (149020, 171010), srid=3812)
self.system2 = ITSBollardSystem.objects.create(
code="BS-002",
name_fr="Traverse Bornes Boulevard",
status="active",
width=8.0,
direction="entry_only",
geom=line2_3812,
lon=4.3600,
lat=50.8500
)
ITSBollardSystemContract.objects.create(
system=self.system2,
contract=self.contract_other,
status="active"
)
# Bollard System 3 (Removed status - should be excluded)
line3_3812 = LineString((150000, 172000), (150020, 172010), srid=3812)
self.system_removed = ITSBollardSystem.objects.create(
code="BS-REMOVED",
name_fr="Traverse Supprimée",
status="removed",
geom=line3_3812,
lon=4.3700,
lat=50.8600
)
self.client = Client()
def test_superuser_sees_all_active_assets(self):
self.client.force_login(self.superuser)
url = reverse('assets:its_bollard_systems_geojson')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get('type'), 'FeatureCollection')
features = data.get('features', [])
codes = [f['properties']['code'] for f in features]
# Check system 1 & 2 are included, removed system is excluded
self.assertIn("BS-001", codes)
self.assertIn("BS-002", codes)
self.assertNotIn("BS-REMOVED", codes)
# Check plots are included
self.assertIn("PL-001", codes)
self.assertIn("PL-002", codes)
# Check camera is included
self.assertIn("CAM-001", codes)
# Check feature properties for system
bs1_feat = next(f for f in features if f['properties']['code'] == 'BS-001')
self.assertEqual(bs1_feat['geometry']['type'], 'LineString')
self.assertEqual(bs1_feat['properties']['asset_type'], 'itsbollardsystem')
self.assertEqual(bs1_feat['properties']['type'], 'asset')
self.assertEqual(bs1_feat['properties']['width'], 6.5)
self.assertEqual(bs1_feat['properties']['plots_count'], 2)
self.assertEqual(bs1_feat['properties']['location_id'], self.location.id)
# Check feature properties for plot
pl1_feat = next(f for f in features if f['properties']['code'] == 'PL-001')
self.assertEqual(pl1_feat['geometry']['type'], 'Point')
self.assertEqual(pl1_feat['properties']['asset_type'], 'itsbollardplot')
self.assertEqual(pl1_feat['properties']['type'], 'asset')
self.assertEqual(pl1_feat['properties']['position'], 1)
self.assertEqual(pl1_feat['properties']['system_code'], 'BS-001')
# Check feature properties for camera
cam_feat = next(f for f in features if f['properties']['code'] == 'CAM-001')
self.assertEqual(cam_feat['geometry']['type'], 'Point')
self.assertEqual(cam_feat['properties']['asset_type'], 'itsgeoasset')
self.assertTrue(cam_feat['properties']['is_camera'])
self.assertEqual(cam_feat['properties']['role'], 'camera_entry')
self.assertEqual(cam_feat['properties']['system_code'], 'BS-001')
def test_external_user_restricted_by_contracts(self):
self.client.force_login(self.user_external)
url = reverse('assets:its_bollard_systems_geojson')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
codes = [f['properties']['code'] for f in features]
# External user should only see BS-001 and its plots/cameras
self.assertIn("BS-001", codes)
self.assertIn("PL-001", codes)
self.assertIn("CAM-001", codes)
# System 2 is not linked to their contract
self.assertNotIn("BS-002", codes)
def test_internal_user_can_view_all_active_systems(self):
self.client.force_login(self.user_internal)
url = reverse('assets:its_bollard_systems_geojson')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
codes = [f['properties']['code'] for f in features]
self.assertIn("BS-001", codes)
self.assertIn("BS-002", codes)
self.assertNotIn("BS-REMOVED", codes)
def test_unauthenticated_user_forbidden(self):
url = reverse('assets:its_bollard_systems_geojson')
response = self.client.get(url)
self.assertIn(response.status_code, [302, 403])
def test_get_nearest_its_location(self):
self.client.force_login(self.user_internal)
url = reverse('assets:get_nearest_its_location')
response = self.client.get(url, {'lon': 4.3517, 'lat': 50.8400})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data.get('location_id'), self.location.id)
self.assertIn(self.location.code, data.get('location_label', ''))
def test_get_nearest_its_location_invalid_coords(self):
self.client.force_login(self.user_internal)
url = reverse('assets:get_nearest_its_location')
response = self.client.get(url, {'lon': 'invalid', 'lat': 'invalid'})
self.assertEqual(response.status_code, 400)

View file

@ -217,11 +217,13 @@ urlpatterns = [
path('its/', its.its_index, name='its_index'),
path('its/locations/', its.its_locations, name='its_locations'),
path('its/locations/geojson/', its.its_locations_geojson, name='its_locations_geojson'),
path('its/locations/get-nearest/', its.get_nearest_its_location, name='get_nearest_its_location'),
path('its/locations/<int:location_id>/', its.its_locations_detail, name='its_locations_detail'),
path('its/locations/<int:location_id>/add-document/', its.its_location_add_document, name='its_location_add_document'),
path('its/locations/<int:document_id>/delete-document/', its.its_location_delete_document, name='its_location_delete_document'),
path('its/assets/', its.its_assets, name='its_assets'),
path('its/assets/geojson/', its.its_assets_geojson, name='its_assets_geojson'),
path('its/bollard-systems/geojson/', its.its_bollard_systems_geojson, name='its_bollard_systems_geojson'),
path('its/assets/autocomplete/', its.its_assets_autocomplete, name='its_autocomplete_assets'),
path('its/<str:asset_model>/<int:asset_id>/', its.its_assets_detail, name='its_assets_detail'),

View file

@ -491,6 +491,263 @@ def its_assets_geojson(request):
@check_thematic_access('its')
@require_GET
def its_bollard_systems_geojson(request):
"""
Returns a GeoJSON FeatureCollection of retractable bollard system assets:
- LineString features for ITSBollardSystem
- Point features for ITSBollardPlot
- Point features for linked cameras (ITSGeoAsset linked via ITSBollardSystemAsset)
"""
bollard_system_ct = ContentType.objects.get_for_model(ITSBollardSystem)
bollard_plot_ct = ContentType.objects.get_for_model(ITSBollardPlot)
geoasset_ct = ContentType.objects.get_for_model(ITSGeoAsset)
location_ct = ContentType.objects.get_for_model(ITSLocation)
bollard_system_qs = (
ITSBollardSystem.objects
.select_related('location', 'category', 'model')
.prefetch_related('plots')
.exclude(status='removed')
)
# Restriction par contrat pour utilisateurs externes
user = request.user
if user.is_authenticated:
try:
user_config = user.config
except UserConfig.DoesNotExist:
user_config = None
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
if must_limit:
accessible_contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_view_assets=True
).values_list('contract_id', flat=True)
bollard_system_qs = bollard_system_qs.filter(
bollard_systems_contracts__status='active',
bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
else:
bollard_system_qs = bollard_system_qs.none()
def _extract_geom_and_coords(obj):
geometry = None
if getattr(obj, 'geojson', None):
try:
geometry = json.loads(obj.geojson)
except Exception:
geometry = None
lon = getattr(obj, 'lon', None)
lat = getattr(obj, 'lat', None)
if (lon is None or lat is None or geometry is None) and getattr(obj, 'geom', None):
try:
g = obj.geom
if g.srid and g.srid != 4326:
g = g.transform(4326, clone=True)
if geometry is None:
geometry = json.loads(g.geojson)
coords = getattr(g, 'coords', None)
if coords:
if isinstance(coords[0], (int, float)):
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
elif isinstance(coords[0], (list, tuple)) and coords[0]:
mid = len(coords) // 2
if lon is None: lon = coords[mid][0]
if lat is None: lat = coords[mid][1]
except Exception:
pass
if geometry is None and lon is not None and lat is not None:
geometry = {
"type": "Point",
"coordinates": [lon, lat]
}
return geometry, lon, lat
features = []
# 1. Bollard Systems (Lines)
for system in bollard_system_qs:
geometry, lon, lat = _extract_geom_and_coords(system)
if not geometry:
continue
loc = system.location
loc_label = f"{loc.get_name()} ({loc.code})" if (loc and loc.code) else (loc.get_name() if loc else None)
label = f"{system.code} - {system.get_name()}" if system.get_name() else system.code
features.append({
"type": "Feature",
"id": system.id,
"geometry": geometry,
"properties": {
"type": "asset",
"asset_type": "itsbollardsystem",
"id": system.id,
"ct": bollard_system_ct.id,
"code": system.code,
"name": system.get_name(),
"label": label,
"equipment_type": _("Système de bornes"),
"status": system.status,
"width": system.width,
"direction": system.get_direction_display() if hasattr(system, 'get_direction_display') else system.direction,
"plots_count": system.plots.count(),
"lon": lon,
"lat": lat,
"location_id": loc.id if loc else None,
"location_ct": location_ct.id if loc else None,
"location_label": loc_label,
}
})
# 2. Bollard Plots (Points)
bollard_plot_qs = (
ITSBollardPlot.objects
.filter(system__in=bollard_system_qs)
.select_related('system', 'location', 'category', 'model', 'system__location')
.exclude(status='removed')
)
for plot in bollard_plot_qs:
geometry, lon, lat = _extract_geom_and_coords(plot)
if not geometry and plot.system:
_ignored, lon, lat = _extract_geom_and_coords(plot.system)
if lon is not None and lat is not None:
geometry = {"type": "Point", "coordinates": [lon, lat]}
if not geometry:
continue
loc = plot.location or (plot.system.location if plot.system else None)
loc_label = f"{loc.get_name()} ({loc.code})" if (loc and loc.code) else (loc.get_name() if loc else None)
plot_name = plot.get_name() or f"Plot #{plot.position}"
label = f"{plot.code} - {plot_name}" if plot.code else plot_name
features.append({
"type": "Feature",
"id": plot.id,
"geometry": geometry,
"properties": {
"type": "asset",
"asset_type": "itsbollardplot",
"id": plot.id,
"ct": bollard_plot_ct.id,
"code": plot.code,
"name": plot_name,
"label": label,
"equipment_type": plot.get_bollard_type_display() if hasattr(plot, 'get_bollard_type_display') else _("Plot de borne"),
"bollard_type": plot.bollard_type,
"bollard_type_display": plot.get_bollard_type_display() if hasattr(plot, 'get_bollard_type_display') else plot.bollard_type,
"status": plot.status,
"position": plot.position,
"system_id": plot.system_id,
"system_code": plot.system.code if plot.system else None,
"lon": lon,
"lat": lat,
"location_id": loc.id if loc else None,
"location_ct": location_ct.id if loc else None,
"location_label": loc_label,
}
})
# 3. Linked Cameras (Points)
camera_roles = [
ITSBollardSystemAsset.AssetRole.CAMERA_ENTRY,
ITSBollardSystemAsset.AssetRole.CAMERA_EXIT,
ITSBollardSystemAsset.AssetRole.CAMERA_CCTV,
]
system_assets_qs = (
ITSBollardSystemAsset.objects
.filter(bollard_system__in=bollard_system_qs)
.filter(
Q(role__in=camera_roles) |
Q(role__icontains='camera') |
Q(asset__category__name_fr__icontains='caméra') |
Q(asset__category__name_nl__icontains='camera') |
Q(asset__category__code__icontains='camera') |
Q(asset__model__name_fr__icontains='caméra') |
Q(asset__model__name_nl__icontains='camera') |
Q(asset__model__code__icontains='camera')
)
.select_related(
'asset', 'asset__model', 'asset__category', 'bollard_system',
'asset__location', 'bollard_system__location',
'asset__trafficlights_pole', 'asset__lighting_pole'
)
.exclude(asset__status='removed')
)
seen_camera_ids = set()
for link in system_assets_qs:
camera = link.asset
if camera.id in seen_camera_ids:
continue
seen_camera_ids.add(camera.id)
geometry, lon, lat = _extract_geom_and_coords(camera)
if not geometry:
support = camera.trafficlights_pole or camera.lighting_pole
if support:
geometry, lon, lat = _extract_geom_and_coords(support)
if not geometry and link.bollard_system:
_ignored, lon, lat = _extract_geom_and_coords(link.bollard_system)
if lon is not None and lat is not None:
geometry = {"type": "Point", "coordinates": [lon, lat]}
if not geometry:
continue
loc = camera.location or (link.bollard_system.location if link.bollard_system else None)
loc_label = f"{loc.get_name()} ({loc.code})" if (loc and loc.code) else (loc.get_name() if loc else None)
cam_name = camera.get_name() or link.get_role_display()
label = f"{camera.code} - {cam_name}" if camera.code else cam_name
features.append({
"type": "Feature",
"id": camera.id,
"geometry": geometry,
"properties": {
"type": "asset",
"asset_type": "itsgeoasset",
"is_camera": True,
"id": camera.id,
"ct": geoasset_ct.id,
"code": camera.code,
"name": cam_name,
"label": label,
"equipment_type": link.get_role_display() or _("Caméra"),
"status": camera.status,
"role": link.role,
"role_display": link.get_role_display(),
"comment": link.comment,
"system_id": link.bollard_system_id,
"system_code": link.bollard_system.code if link.bollard_system else None,
"lon": lon,
"lat": lat,
"location_id": loc.id if loc else None,
"location_ct": location_ct.id if loc else None,
"location_label": loc_label,
}
})
return JsonResponse({
"type": "FeatureCollection",
"features": features,
}, safe=False)
@check_thematic_access('its')
def its_locations_detail(request, location_id):
# Préfetch (réduction N+1) : ASSETS + enfants (2 niveaux)

View file

@ -186,7 +186,7 @@ function updateSelectedEquipmentsUI() {
}
}
function applyEquipmentLocation(item) {
function applyEquipmentLocation(item, options = {}) {
if (!item) {
return;
}
@ -247,7 +247,7 @@ function applyEquipmentLocation(item) {
}
}
if (item.lon && item.lat) {
if (!options.preserveCoordinates && item.lon && item.lat) {
$('#id_lon').val(item.lon);
$('#id_lat').val(item.lat);
centerMap();
@ -279,7 +279,7 @@ function addEquipmentSelection(item, options = {}) {
}
updateSelectedEquipmentsUI();
applyEquipmentLocation(item);
applyEquipmentLocation(item, options);
}
$(function() {

View file

@ -222,7 +222,17 @@ document.addEventListener('DOMContentLoaded', function () {
const isSelected = selectedEquipments.some(s => s.equipment_ct == eq.ct && s.equipment_id == eq.id);
html += `
<div class="form-check py-1 px-3">
<input class="form-check-input select-eq-cb" type="checkbox" id="eq-cb-${idx}" data-ct="${eq.ct}" data-id="${eq.id}" data-label="${eq.label}" data-lon="${eq.lon}" data-lat="${eq.lat}" ${isSelected ? 'checked' : ''}>
<input class="form-check-input select-eq-cb" type="checkbox" id="eq-cb-${idx}"
data-ct="${eq.ct}"
data-id="${eq.id}"
data-label="${eq.label}"
data-equipment-type="${eq.equipment_type || ''}"
data-location-id="${eq.location_id || ''}"
data-location-ct="${eq.location_ct || ''}"
data-location-label="${eq.location_label || ''}"
data-lon="${eq.lon}"
data-lat="${eq.lat}"
${isSelected ? 'checked' : ''}>
<label class="form-check-label ms-1 text-truncate" style="max-width: 180px; cursor: pointer; font-size: 0.85rem;" for="eq-cb-${idx}">
${eq.label}
</label>
@ -252,11 +262,15 @@ document.addEventListener('DOMContentLoaded', function () {
const item = {
equipment_id: cb.dataset.id,
equipment_ct: cb.dataset.ct,
equipment_type: cb.dataset.equipmentType || '',
label: cb.dataset.label,
location_id: cb.dataset.locationId || null,
location_ct: cb.dataset.locationCt || null,
location_label: cb.dataset.locationLabel || null,
lon: parseFloat(cb.dataset.lon),
lat: parseFloat(cb.dataset.lat)
};
addEquipmentSelection(item, { replaceExisting: false });
addEquipmentSelection(item, { replaceExisting: false, preserveCoordinates: true });
}
});
// NOTE: marker stays at click position — not moved to any equipment.
@ -330,8 +344,18 @@ document.addEventListener('DOMContentLoaded', function () {
'traffic-lights',
'controlcenters',
'fountains',
'monuments'
].filter(layerId => map.getLayer(layerId));
'monuments',
'its-bollard-systems-line',
'its-bollard-plots-circle',
'its-cameras-circle'
].filter(layerId => {
if (!map.getLayer(layerId)) return false;
try {
return map.getLayoutProperty(layerId, 'visibility') !== 'none';
} catch (err) {
return true;
}
});
const bbox = [
[e.point.x - 5, e.point.y - 5],
@ -346,13 +370,13 @@ document.addEventListener('DOMContentLoaded', function () {
clickedFeatures.forEach(feat => {
const props = feat.properties || {};
const type = props.type; // 'asset' or 'location' for inspection-zones
const type = props.type; // 'asset' or 'location'
// 1. From our custom inspection configuration layers
if (feat.layer.id.startsWith('inspection-zones')) {
// 1. From our custom inspection configuration layers or ITS bollard layers
if (feat.layer.id.startsWith('inspection-zones') || feat.layer.id.startsWith('its-') || type === 'asset') {
const ct = props.ct;
const id = props.id;
const label = props.label || props.name || props.code || `${type} ${id}`;
const id = props.id || feat.id;
const label = props.label || props.name || props.code || `${type || 'asset'} ${id}`;
let lon = props.lon ? parseFloat(props.lon) : null;
let lat = props.lat ? parseFloat(props.lat) : null;
@ -366,11 +390,21 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
if (type === 'asset' && ct && id) {
if ((type === 'asset' || feat.layer.id.startsWith('its-')) && ct && id) {
const key = `${ct}:${id}`;
if (!seenEquip.has(key)) {
seenEquip.add(key);
equipments.push({ ct: ct, id: id, label: label, lon: lon, lat: lat });
equipments.push({
ct: ct,
id: id,
label: label,
equipment_type: props.equipment_type || '',
location_id: props.location_id || null,
location_ct: props.location_ct || null,
location_label: props.location_label || null,
lon: lon,
lat: lat
});
}
} else if (type === 'location' && ct && id) {
const key = `${ct}:${id}`;
@ -423,10 +457,14 @@ document.addEventListener('DOMContentLoaded', function () {
addEquipmentSelection({
equipment_id: eq.id,
equipment_ct: eq.ct,
equipment_type: eq.equipment_type || '',
label: eq.label,
location_id: eq.location_id,
location_ct: eq.location_ct,
location_label: eq.location_label,
lon: eq.lon,
lat: eq.lat
}, { replaceExisting: true });
}, { replaceExisting: true, preserveCoordinates: true });
// NOTE: marker stays at click position — not moved to equipment coordinates.
}
// 2) Si plusieurs équipements sont présents, popup checkboxes

View file

@ -48,6 +48,7 @@
var autocomplete_assets_url = "{% url 'assets:sign_autocomplete_assets' %}";
{% elif thematic.code == 'its' %}
var autocomplete_assets_url = "{% url 'assets:its_autocomplete_assets' %}";
var its_bollard_systems_geojson_url = "{% url 'assets:its_bollard_systems_geojson' %}";
{% elif thematic.code == 'clean' %}
var autocomplete_assets_url = "{% url 'assets:clean_autocomplete_assets' %}";
{% else %}
@ -362,6 +363,308 @@
map.on('load', setupArtworks);
}
</script>
{% elif thematic.code == 'its' %}
<script>
const setupITSBollardSystems = async () => {
if (map.getSource('its-bollards')) return;
const geojsonUrl = typeof its_bollard_systems_geojson_url !== 'undefined'
? its_bollard_systems_geojson_url
: "{% url 'assets:its_bollard_systems_geojson' %}";
let geojsonData = { type: 'FeatureCollection', features: [] };
try {
const resp = await fetch(geojsonUrl);
if (resp.ok) {
geojsonData = await resp.json();
}
} catch (err) {
console.error("Erreur lors du chargement des bornes ITS:", err);
return;
}
map.addSource('its-bollards', {
type: 'geojson',
data: geojsonData
});
// 1. Casing / contour pour la ligne du système de bornes
map.addLayer({
id: 'its-bollard-systems-casing',
type: 'line',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsbollardsystem'],
layout: {
'line-cap': 'round',
'line-join': 'round',
'visibility': 'none'
},
paint: {
'line-color': '#ffffff',
'line-width': 8,
'line-opacity': 0.8
}
});
// 2. Ligne du système de bornes (traverses)
map.addLayer({
id: 'its-bollard-systems-line',
type: 'line',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsbollardsystem'],
layout: {
'line-cap': 'round',
'line-join': 'round',
'visibility': 'none'
},
paint: {
'line-color': '#0288d1',
'line-width': 5,
'line-opacity': 0.95
}
});
// 3. Labels pour les systèmes de bornes (lignes)
map.addLayer({
id: 'its-bollard-systems-labels',
type: 'symbol',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsbollardsystem'],
layout: {
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 12,
'symbol-placement': 'line-center',
'text-offset': [0, -1],
'text-anchor': 'bottom',
'text-allow-overlap': false,
'visibility': 'none'
},
paint: {
'text-color': '#01579b',
'text-halo-color': '#ffffff',
'text-halo-width': 2
},
minzoom: 12
});
// 4. Points pour les plots / bornes
map.addLayer({
id: 'its-bollard-plots-circle',
type: 'circle',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsbollardplot'],
layout: {
'visibility': 'none'
},
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
12, 4,
15, 7,
18, 10
],
'circle-color': '#0288d1',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.95
}
});
// 5. Labels pour les plots / bornes
map.addLayer({
id: 'its-bollard-plots-labels',
type: 'symbol',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsbollardplot'],
layout: {
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 11,
'text-offset': [0, 1.3],
'text-anchor': 'top',
'text-allow-overlap': false,
'visibility': 'none'
},
paint: {
'text-color': '#01579b',
'text-halo-color': '#ffffff',
'text-halo-width': 2
},
minzoom: 13
});
// 6. Points pour les caméras liées
map.addLayer({
id: 'its-cameras-circle',
type: 'circle',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsgeoasset'],
layout: {
'visibility': 'none'
},
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
12, 5,
15, 8,
18, 11
],
'circle-color': '#e65100',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.95
}
});
// 7. Labels pour les caméras liées
map.addLayer({
id: 'its-cameras-labels',
type: 'symbol',
source: 'its-bollards',
filter: ['==', ['get', 'asset_type'], 'itsgeoasset'],
layout: {
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 11,
'text-offset': [0, 1.3],
'text-anchor': 'top',
'text-allow-overlap': false,
'visibility': 'none'
},
paint: {
'text-color': '#bf360c',
'text-halo-color': '#ffffff',
'text-halo-width': 2
},
minzoom: 13
});
// Curseur pointer au survol
const interactiveLayers = ['its-bollard-systems-line', 'its-bollard-plots-circle', 'its-cameras-circle'];
interactiveLayers.forEach(lId => {
map.on('mouseenter', lId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', lId, () => { map.getCanvas().style.cursor = ''; });
});
// Tooltip / Popup au survol
const hoverPopup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
offset: 12
});
interactiveLayers.forEach(lId => {
map.on('mousemove', lId, (e) => {
if (!e.features || !e.features.length) return;
const feat = e.features[0];
const p = feat.properties;
let title = p.label || p.code || '';
let subtitle = p.equipment_type || '';
if (p.system_code && p.asset_type !== 'itsbollardsystem') {
subtitle += ` (${gettext('Système')}: ${p.system_code})`;
}
hoverPopup.setLngLat(e.lngLat)
.setHTML(`<div style="padding: 2px 4px; font-size: 0.85rem;"><strong>${title}</strong><br><span class="text-muted">${subtitle}</span></div>`)
.addTo(map);
});
map.on('mouseleave', lId, () => {
hoverPopup.remove();
});
});
// Gestion de la visibilité selon la catégorie sélectionnée
const itsLayers = [
'its-bollard-systems-casing',
'its-bollard-systems-line',
'its-bollard-systems-labels',
'its-bollard-plots-circle',
'its-bollard-plots-labels',
'its-cameras-circle',
'its-cameras-labels'
];
function isBollardsCategorySelected() {
const catSelect = document.getElementById('id_asset_category');
const subCatSelect = document.getElementById('id_interv_sub_category');
const check = (opt) => {
if (!opt || !opt.value) return false;
const txt = (opt.textContent || opt.text || '').toLowerCase();
return txt.includes('borne') || txt.includes('bollard') || txt.includes('rétractable') || txt.includes('escamotable') || txt.includes('poller') || txt.includes('paal');
};
if (subCatSelect && subCatSelect.selectedIndex >= 0 && check(subCatSelect.options[subCatSelect.selectedIndex])) {
return true;
}
if (catSelect && catSelect.selectedIndex >= 0 && check(catSelect.options[catSelect.selectedIndex])) {
return true;
}
return false;
}
let hasFittedBounds = false;
function updateITSBollardsVisibility() {
const isVisible = isBollardsCategorySelected();
itsLayers.forEach(lId => {
if (map.getLayer(lId)) {
map.setLayoutProperty(lId, 'visibility', isVisible ? 'visible' : 'none');
}
});
// Si rendu visible et pas de coordonnées existantes sur le formulaire, centrer sur les assets
if (isVisible && !hasFittedBounds && geojsonData.features && geojsonData.features.length > 0) {
const latVal = parseFloat(document.getElementById('id_lat')?.value);
const lonVal = parseFloat(document.getElementById('id_lon')?.value);
if (isNaN(latVal) || isNaN(lonVal)) {
const bounds = new maplibregl.LngLatBounds();
geojsonData.features.forEach(f => {
if (f.geometry && f.geometry.type === 'Point' && f.geometry.coordinates) {
bounds.extend(f.geometry.coordinates);
} else if (f.geometry && f.geometry.type === 'LineString' && f.geometry.coordinates) {
f.geometry.coordinates.forEach(c => bounds.extend(c));
}
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 60, maxZoom: 16 });
hasFittedBounds = true;
}
}
}
}
// Écouter les changements sur la catégorie principale et sous-catégorie
const catSelect = document.getElementById('id_asset_category');
if (catSelect) {
catSelect.addEventListener('change', updateITSBollardsVisibility);
}
const subCatSelect = document.getElementById('id_interv_sub_category');
if (subCatSelect) {
subCatSelect.addEventListener('change', updateITSBollardsVisibility);
}
// Observer les mutations éventuelles du bloc de sous-catégorie
const subCatBlock = document.getElementById('interv-sub-category-block');
if (subCatBlock) {
const observer = new MutationObserver(() => {
updateITSBollardsVisibility();
const newSub = document.getElementById('id_interv_sub_category');
if (newSub) {
newSub.removeEventListener('change', updateITSBollardsVisibility);
newSub.addEventListener('change', updateITSBollardsVisibility);
}
});
observer.observe(subCatBlock, { childList: true, subtree: true });
}
// Vérification initiale
updateITSBollardsVisibility();
};
if (map.loaded()) {
setupITSBollardSystems();
} else {
map.on('load', setupITSBollardSystems);
}
</script>
{% endif %}
<script>