feat: add exceptional transport dashboard widget with role-based access and map visualization
This commit is contained in:
parent
3ebe87099d
commit
1ee9164faf
7 changed files with 863 additions and 1 deletions
|
|
@ -254,3 +254,54 @@ class ExceptionalTransportTests(TestCase):
|
||||||
|
|
||||||
folders_te = filter_folders_for_user(DocumentFolder.objects.all(), user_te_viewer)
|
folders_te = filter_folders_for_user(DocumentFolder.objects.all(), user_te_viewer)
|
||||||
self.assertIn(te_folder, folders_te)
|
self.assertIn(te_folder, folders_te)
|
||||||
|
|
||||||
|
def test_exceptional_transport_geojson_endpoint(self):
|
||||||
|
# Set geometry on location
|
||||||
|
from django.contrib.gis.geos import MultiPolygon, Polygon
|
||||||
|
p = Polygon(((4.35, 50.84), (4.36, 50.84), (4.36, 50.85), (4.35, 50.85), (4.35, 50.84)))
|
||||||
|
p.srid = 4326
|
||||||
|
p.transform(3812)
|
||||||
|
self.location.geom = MultiPolygon(p, srid=3812)
|
||||||
|
self.location.save()
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
url = reverse("assets:structures_exceptional_transport_geojson")
|
||||||
|
|
||||||
|
# Anonymous / unauthenticated (redirects to login)
|
||||||
|
res = client.get(url)
|
||||||
|
self.assertIn(res.status_code, (302, 401, 403))
|
||||||
|
|
||||||
|
# User without TE profile
|
||||||
|
client.force_login(self.user_no_te)
|
||||||
|
res = client.get(url)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json()["features"], [])
|
||||||
|
|
||||||
|
# User with TE profile
|
||||||
|
client.force_login(self.user_te_viewer)
|
||||||
|
res = client.get(url)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = res.json()
|
||||||
|
self.assertEqual(data["type"], "FeatureCollection")
|
||||||
|
self.assertEqual(len(data["features"]), 1)
|
||||||
|
feat = data["features"][0]
|
||||||
|
self.assertEqual(feat["properties"]["structure_code"], "OA-TEST-001")
|
||||||
|
self.assertEqual(feat["properties"]["fod_vnr"], 1234)
|
||||||
|
self.assertEqual(feat["properties"]["passage_status"], "conditional")
|
||||||
|
|
||||||
|
def test_home_widget_exceptional_transport_resolution(self):
|
||||||
|
from home.models import HomeWidget
|
||||||
|
widget = HomeWidget.objects.filter(widget_code="exceptional_transport").first()
|
||||||
|
self.assertIsNotNone(widget)
|
||||||
|
|
||||||
|
cfg_no_te = self.user_no_te.config
|
||||||
|
cfg_viewer = self.user_te_viewer.config
|
||||||
|
|
||||||
|
# User without profile should not resolve widget
|
||||||
|
res_no = widget.resolve_for_user(cfg_no_te, None, None)
|
||||||
|
self.assertIsNone(res_no)
|
||||||
|
|
||||||
|
# User with TE profile should resolve widget
|
||||||
|
res_yes = widget.resolve_for_user(cfg_viewer, None, None)
|
||||||
|
self.assertIsNotNone(res_yes)
|
||||||
|
self.assertEqual(res_yes["template_name"], "home/widgets/exceptional_transport.html")
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ urlpatterns = [
|
||||||
path('structures/locations/<int:location_id>/', structures.structures_locations_detail, name='structures_locations_detail'),
|
path('structures/locations/<int:location_id>/', structures.structures_locations_detail, name='structures_locations_detail'),
|
||||||
path('structures/assets/', structures.structures_assets, name='structures_assets'),
|
path('structures/assets/', structures.structures_assets, name='structures_assets'),
|
||||||
path('structures/assets/geojson/', structures.structures_assets_geojson, name='structures_assets_geojson'),
|
path('structures/assets/geojson/', structures.structures_assets_geojson, name='structures_assets_geojson'),
|
||||||
|
path('structures/exceptional-transport/geojson/', structures.structures_exceptional_transport_geojson, name='structures_exceptional_transport_geojson'),
|
||||||
path('structures/structures/<int:structure_id>/edit-exceptional-transport/', structures.structures_edit_exceptional_transport, name='structures_edit_exceptional_transport'),
|
path('structures/structures/<int:structure_id>/edit-exceptional-transport/', structures.structures_edit_exceptional_transport, name='structures_edit_exceptional_transport'),
|
||||||
path('structures/<str:asset_model>/<int:asset_id>/', structures.structures_assets_detail, name='structures_assets_detail'),
|
path('structures/<str:asset_model>/<int:asset_id>/', structures.structures_assets_detail, name='structures_assets_detail'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
# ----------------
|
# ----------------
|
||||||
|
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse, Http404
|
from django.http import JsonResponse, Http404
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
from django.contrib.gis.db.models.functions import Transform, Distance
|
from django.contrib.gis.db.models.functions import Transform, Distance
|
||||||
|
|
@ -720,6 +721,96 @@ def structures_edit_exceptional_transport(request, structure_id):
|
||||||
return JsonResponse({'success': False, 'errors': form.errors}, status=400)
|
return JsonResponse({'success': False, 'errors': form.errors}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def structures_exceptional_transport_geojson(request):
|
||||||
|
"""Retourne l'ensemble des ouvrages avec données de transport exceptionnel au format GeoJSON."""
|
||||||
|
if not request.user.is_authenticated or not can_view_exceptional_transport(request.user):
|
||||||
|
return JsonResponse({'type': 'FeatureCollection', 'features': []})
|
||||||
|
|
||||||
|
te_list = (
|
||||||
|
StructureExceptionalTransport.objects
|
||||||
|
.select_related('structure', 'structure__main_location')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
features = []
|
||||||
|
for te in te_list:
|
||||||
|
structure = te.structure
|
||||||
|
loc = structure.main_location
|
||||||
|
if not loc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lon, lat = None, None
|
||||||
|
if loc.geom:
|
||||||
|
try:
|
||||||
|
pt = loc.geom.centroid.transform(4326, clone=True)
|
||||||
|
lon = pt.x
|
||||||
|
lat = pt.y
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if (lon is None or lat is None) and loc.lon and loc.lat:
|
||||||
|
try:
|
||||||
|
lon = float(loc.lon)
|
||||||
|
lat = float(loc.lat)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if lon is None or lat is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
detail_url = reverse('assets:structures_locations_detail', args=[loc.id])
|
||||||
|
|
||||||
|
h_str = ""
|
||||||
|
if te.clearance_height_left_m or te.clearance_height_center_m or te.clearance_height_right_m:
|
||||||
|
h_left = f"{te.clearance_height_left_m}m" if te.clearance_height_left_m else "-"
|
||||||
|
h_center = f"{te.clearance_height_center_m}m" if te.clearance_height_center_m else "-"
|
||||||
|
h_right = f"{te.clearance_height_right_m}m" if te.clearance_height_right_m else "-"
|
||||||
|
h_str = f"{h_left} / {h_center} / {h_right}"
|
||||||
|
|
||||||
|
features.append({
|
||||||
|
'type': 'Feature',
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Point',
|
||||||
|
'coordinates': [lon, lat]
|
||||||
|
},
|
||||||
|
'properties': {
|
||||||
|
'id': te.id,
|
||||||
|
'structure_id': structure.id,
|
||||||
|
'structure_code': structure.code or '',
|
||||||
|
'name': structure.get_name(),
|
||||||
|
'name_fr': structure.name_fr or '',
|
||||||
|
'name_nl': structure.name_nl or '',
|
||||||
|
'structure_type': structure.get_structure_type_display() if hasattr(structure, 'get_structure_type_display') else (structure.structure_type or ''),
|
||||||
|
'location_id': loc.id,
|
||||||
|
'location_code': loc.code or '',
|
||||||
|
'detail_url': detail_url,
|
||||||
|
'fod_vnr': te.fod_vnr,
|
||||||
|
'national_id': te.national_id or '',
|
||||||
|
'bridge_class': te.bridge_class or '',
|
||||||
|
'passage_status': te.passage_status,
|
||||||
|
'passage_status_display': str(te.get_passage_status_display()),
|
||||||
|
'max_authorized_weight_tonnes': float(te.max_authorized_weight_tonnes) if te.max_authorized_weight_tonnes is not None else None,
|
||||||
|
'min_guaranteed_capacity_tonnes': float(te.min_guaranteed_capacity_tonnes) if te.min_guaranteed_capacity_tonnes is not None else None,
|
||||||
|
'max_calculated_weight_tonnes': float(te.max_calculated_weight_tonnes) if te.max_calculated_weight_tonnes is not None else None,
|
||||||
|
'usable_width_m': float(te.usable_width_m) if te.usable_width_m is not None else None,
|
||||||
|
'clearance_heights_str': h_str,
|
||||||
|
'max_speed_kmh': te.max_speed_kmh,
|
||||||
|
'is_braking_forbidden': te.is_braking_forbidden,
|
||||||
|
'is_isolated_crossing_only': te.is_isolated_crossing_only,
|
||||||
|
'safety_distance_m': float(te.safety_distance_m) if te.safety_distance_m is not None else None,
|
||||||
|
'prescriptions_fr': te.prescriptions_fr or '',
|
||||||
|
'prescriptions_nl': te.prescriptions_nl or '',
|
||||||
|
'last_study_ref': te.last_study_ref or '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'type': 'FeatureCollection',
|
||||||
|
'features': features
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@check_thematic_access('structures')
|
@check_thematic_access('structures')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-08-21 09:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0004_alter_homewidget_widget_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='homewidget',
|
||||||
|
name='widget_code',
|
||||||
|
field=models.CharField(choices=[('intervention_stats', 'Statistiques interventions'), ('contracts_summary', 'Résumé contrats'), ('map_today', 'Carte du jour'), ('operator_shortcuts', 'Raccourcis opérateur'), ('assets_links', 'Liens assets'), ('stock_summary', 'Résumé stock'), ('observer_shortcuts', 'Raccourcis observateur'), ('incident_counter', 'Compteur incidents à traiter'), ('intervention_to_verify', 'Compteur interventions à vérifier'), ('intervention_to_plan', 'Compteur interventions à planifier'), ('intervention_to_approve', 'Compteur interventions à approuver'), ('intervention_checked_to_approve', 'Compteur interventions vérifiées à approuver'), ('intervention_finished', 'Compteur interventions terminées'), ('intervention_on_pause', 'Compteur interventions en pause'), ('external_interventions', 'Interventions externes'), ('stock_actions', 'Actions stock'), ('exceptional_transport', 'Transport exceptionnel')], max_length=50, unique=True, verbose_name='Code du widget'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='homewidgetcondition',
|
||||||
|
name='condition_type',
|
||||||
|
field=models.CharField(choices=[('role', 'Rôle'), ('team', 'Équipe'), ('contract', 'Contrat'), ('thematic', 'Thématique'), ('is_intern', 'Statut interne'), ('exceptional_transport', 'Profil Transport Exceptionnel')], max_length=50, verbose_name='Type de condition'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_exceptional_transport_widget(apps, schema_editor):
|
||||||
|
HomeWidget = apps.get_model('home', 'HomeWidget')
|
||||||
|
HomeWidgetConditionGroup = apps.get_model('home', 'HomeWidgetConditionGroup')
|
||||||
|
HomeWidgetCondition = apps.get_model('home', 'HomeWidgetCondition')
|
||||||
|
|
||||||
|
widget, created = HomeWidget.objects.get_or_create(
|
||||||
|
widget_code='exceptional_transport',
|
||||||
|
defaults={'is_active': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not widget.condition_groups.exists():
|
||||||
|
group = HomeWidgetConditionGroup.objects.create(
|
||||||
|
widget=widget,
|
||||||
|
label="Profil Transport Exceptionnel",
|
||||||
|
order=25,
|
||||||
|
col_width=12,
|
||||||
|
priority=0,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
HomeWidgetCondition.objects.create(
|
||||||
|
group=group,
|
||||||
|
condition_type='exceptional_transport',
|
||||||
|
is_active=True,
|
||||||
|
is_negated=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_exceptional_transport_widget(apps, schema_editor):
|
||||||
|
HomeWidget = apps.get_model('home', 'HomeWidget')
|
||||||
|
HomeWidget.objects.filter(widget_code='exceptional_transport').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('home', '0005_alter_homewidget_widget_code_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_exceptional_transport_widget, remove_exceptional_transport_widget),
|
||||||
|
]
|
||||||
|
|
@ -20,6 +20,7 @@ WIDGET_CODE_CHOICES = [
|
||||||
('intervention_on_pause', _('Compteur interventions en pause')),
|
('intervention_on_pause', _('Compteur interventions en pause')),
|
||||||
('external_interventions', _('Interventions externes')),
|
('external_interventions', _('Interventions externes')),
|
||||||
('stock_actions', _('Actions stock')),
|
('stock_actions', _('Actions stock')),
|
||||||
|
('exceptional_transport', _('Transport exceptionnel')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Condition type choices
|
# Condition type choices
|
||||||
|
|
@ -29,6 +30,7 @@ CONDITION_TYPE_CHOICES = [
|
||||||
('contract', _('Contrat')),
|
('contract', _('Contrat')),
|
||||||
('thematic', _('Thématique')),
|
('thematic', _('Thématique')),
|
||||||
('is_intern', _('Statut interne')),
|
('is_intern', _('Statut interne')),
|
||||||
|
('exceptional_transport', _('Profil Transport Exceptionnel')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -221,7 +223,7 @@ class HomeWidgetCondition(models.Model):
|
||||||
verbose_name=_("Groupe")
|
verbose_name=_("Groupe")
|
||||||
)
|
)
|
||||||
condition_type = models.CharField(
|
condition_type = models.CharField(
|
||||||
max_length=20,
|
max_length=50,
|
||||||
choices=CONDITION_TYPE_CHOICES,
|
choices=CONDITION_TYPE_CHOICES,
|
||||||
verbose_name=_("Type de condition")
|
verbose_name=_("Type de condition")
|
||||||
)
|
)
|
||||||
|
|
@ -330,6 +332,9 @@ class HomeWidgetCondition(models.Model):
|
||||||
|
|
||||||
elif self.condition_type == 'is_intern':
|
elif self.condition_type == 'is_intern':
|
||||||
res = user_config.is_intern == self.is_intern
|
res = user_config.is_intern == self.is_intern
|
||||||
|
|
||||||
|
elif self.condition_type == 'exceptional_transport':
|
||||||
|
res = bool(user_config.user.is_superuser or user_config.has_exceptional_transport_profile)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
res = False
|
res = False
|
||||||
|
|
|
||||||
647
loko/home/templates/home/widgets/exceptional_transport.html
Normal file
647
loko/home/templates/home/widgets/exceptional_transport.html
Normal file
|
|
@ -0,0 +1,647 @@
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="card section-card shadow-sm border-0 rounded-4 overflow-hidden mb-4" id="exceptional-transport-widget">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-header bg-white border-bottom py-3 px-4 d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="rounded-3 p-2 d-flex align-items-center justify-content-center" style="background-color: #e8f4fd; color: #007bc5; width: 42px; height: 42px;">
|
||||||
|
<i class="bi bi-truck-front-fill fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="text-start">
|
||||||
|
<h5 class="card-title fw-bold mb-0 text-dark">
|
||||||
|
{% translate "Transport Exceptionnel & Convois Lourds" %}
|
||||||
|
</h5>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% translate "Cartographie du réseau d'ouvrages d'art et conditions d'admissibilité" %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle px-3 py-2 rounded-pill fw-semibold" id="te-total-badge">
|
||||||
|
<i class="bi bi-geo-alt-fill me-1"></i><span id="te-total-count">0</span> {% translate "ouvrages répertoriés" %}
|
||||||
|
</span>
|
||||||
|
<a href="{% url 'assets:structures_index' %}" class="btn btn-outline-secondary btn-sm rounded-pill px-3 py-1 fw-medium" title="{% translate 'Voir tous les ouvrages d\'art' %}">
|
||||||
|
<i class="bi bi-arrow-right me-1"></i>{% translate "Tous les ouvrages" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="card-body p-3 p-md-4">
|
||||||
|
<!-- Controls Bar: Search & Status Filters -->
|
||||||
|
<div class="row g-2 align-items-center mb-3">
|
||||||
|
<!-- Search Autocomplete -->
|
||||||
|
<div class="col-12 col-lg-6 position-relative">
|
||||||
|
<div class="input-group shadow-sm rounded-pill overflow-hidden border">
|
||||||
|
<span class="input-group-text bg-white border-0 ps-3 text-muted">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text"
|
||||||
|
id="te-search-input"
|
||||||
|
class="form-control border-0 py-2 shadow-none"
|
||||||
|
placeholder="{% translate 'Rechercher un ouvrage (code ex: OA120, nom, n° FOD, BKL...)' %}"
|
||||||
|
autocomplete="off">
|
||||||
|
<button class="btn btn-white border-0 pe-3 text-muted d-none" type="button" id="te-search-clear" title="{% translate 'Effacer la recherche' %}">
|
||||||
|
<i class="bi bi-x-circle-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Autocomplete Dropdown List -->
|
||||||
|
<div id="te-autocomplete-dropdown"
|
||||||
|
class="list-group position-absolute w-100 shadow-lg rounded-3 mt-1 overflow-auto border-0 d-none"
|
||||||
|
style="max-height: 320px; z-index: 1060; left: 0; background: rgba(255, 255, 255, 0.98); backdrop-filter: blur(10px);">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter Chips -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="d-flex flex-wrap gap-1 justify-content-lg-end" id="te-status-filters">
|
||||||
|
<button type="button" class="btn btn-sm btn-dark rounded-pill px-3 py-1 te-filter-btn active" data-status="all">
|
||||||
|
{% translate "Tous" %} (<span id="te-count-all">0</span>)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success rounded-pill px-2 py-1 te-filter-btn" data-status="authorized">
|
||||||
|
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Autorisé" %} (<span id="te-count-authorized">0</span>)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning rounded-pill px-2 py-1 te-filter-btn text-dark" data-status="conditional">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1 text-warning"></i>{% translate "Sous conditions" %} (<span id="te-count-conditional">0</span>)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill px-2 py-1 te-filter-btn" data-status="other">
|
||||||
|
{% translate "Autres" %} (<span id="te-count-other">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Container -->
|
||||||
|
<div class="position-relative rounded-4 overflow-hidden border shadow-inner" style="height: 540px;">
|
||||||
|
<div id="te-map-container" style="width: 100%; height: 100%;"></div>
|
||||||
|
|
||||||
|
<!-- Floating Map Controls Overlay (Top Right) -->
|
||||||
|
<div class="position-absolute top-0 end-0 m-3 d-flex flex-column gap-2" style="z-index: 5;">
|
||||||
|
<button type="button" class="btn btn-light btn-sm shadow-sm rounded-3 border" id="te-btn-reset-view" title="{% translate 'Réinitialiser la vue' %}">
|
||||||
|
<i class="bi bi-arrows-fullscreen"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Legend (Bottom Left) -->
|
||||||
|
<div class="position-absolute bottom-0 start-0 m-3 p-2 px-3 rounded-3 shadow-sm border bg-white bg-opacity-95 text-start"
|
||||||
|
style="z-index: 5; font-size: 0.8rem; max-width: 280px; backdrop-filter: blur(8px);">
|
||||||
|
<div class="fw-bold mb-1 text-secondary text-uppercase" style="font-size: 0.7rem; letter-spacing: 0.5px;">
|
||||||
|
{% translate "Statut de passage" %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column gap-1">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded-circle d-inline-block" style="width: 12px; height: 12px; background-color: #198754; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.5);"></span>
|
||||||
|
<span>{% translate "Autorisé (standard / calculé)" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded-circle d-inline-block" style="width: 12px; height: 12px; background-color: #ffc107; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.5);"></span>
|
||||||
|
<span>{% translate "Sous conditions / prescriptions" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded-circle d-inline-block" style="width: 12px; height: 12px; background-color: #fd7e14; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.5);"></span>
|
||||||
|
<span>{% translate "Étude préalable requise" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded-circle d-inline-block" style="width: 12px; height: 12px; background-color: #dc3545; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.5);"></span>
|
||||||
|
<span>{% translate "Interdit" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="rounded-circle d-inline-block" style="width: 12px; height: 12px; background-color: #6c757d; border: 2px solid white; box-shadow: 0 0 2px rgba(0,0,0,0.5);"></span>
|
||||||
|
<span>{% translate "Non évalué" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Spinner Overlay -->
|
||||||
|
<div id="te-map-loading" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75" style="z-index: 10;">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">{% translate "Chargement..." %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="small fw-semibold text-secondary mt-2">{% translate "Chargement du réseau d'ouvrages..." %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Styles for Popup and Autocomplete -->
|
||||||
|
<style>
|
||||||
|
.te-popup .maplibregl-popup-content {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 340px;
|
||||||
|
}
|
||||||
|
.te-popup .maplibregl-popup-close-button {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #495057;
|
||||||
|
padding: 6px 10px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.te-autocomplete-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.te-autocomplete-item:hover, .te-autocomplete-item.active {
|
||||||
|
background-color: #f1f8ff !important;
|
||||||
|
}
|
||||||
|
.te-metric-box {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Widget Logic -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const GEOJSON_URL = "{% url 'assets:structures_exceptional_transport_geojson' %}";
|
||||||
|
let teMap = null;
|
||||||
|
let teFeatures = [];
|
||||||
|
let activeFilter = 'all';
|
||||||
|
let currentPopup = null;
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
'authorized': '#198754',
|
||||||
|
'conditional': '#ffc107',
|
||||||
|
'prior_study_required': '#fd7e14',
|
||||||
|
'forbidden': '#dc3545',
|
||||||
|
'not_evaluated': '#6c757d'
|
||||||
|
};
|
||||||
|
|
||||||
|
function initExceptionalTransportWidget() {
|
||||||
|
const container = document.getElementById('te-map-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Map initialization
|
||||||
|
teMap = new maplibregl.Map({
|
||||||
|
container: 'te-map-container',
|
||||||
|
style: window.MAP_STYLE_LIGHT || 'https://tiles.openfreemap.org/styles/positron',
|
||||||
|
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
|
||||||
|
zoom: window.MAP_DEFAULT_ZOOM || 11.5,
|
||||||
|
minzoom: 8,
|
||||||
|
maxzoom: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
teMap.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-left');
|
||||||
|
|
||||||
|
teMap.on('load', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(GEOJSON_URL);
|
||||||
|
const data = await response.json();
|
||||||
|
teFeatures = (data && data.features) ? data.features : [];
|
||||||
|
|
||||||
|
updateCounts();
|
||||||
|
renderMapLayers();
|
||||||
|
setupSearchAutocomplete();
|
||||||
|
setupStatusFilters();
|
||||||
|
setupResetButton();
|
||||||
|
|
||||||
|
const loadingEl = document.getElementById('te-map-loading');
|
||||||
|
if (loadingEl) loadingEl.classList.add('d-none');
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading Exceptional Transport GeoJSON:", err);
|
||||||
|
const loadingEl = document.getElementById('te-map-loading');
|
||||||
|
if (loadingEl) {
|
||||||
|
loadingEl.innerHTML = `<div class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Impossible de charger les données cartographiques.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
let total = teFeatures.length;
|
||||||
|
let auth = 0, cond = 0, other = 0;
|
||||||
|
|
||||||
|
teFeatures.forEach(f => {
|
||||||
|
const st = f.properties.passage_status;
|
||||||
|
if (st === 'authorized') auth++;
|
||||||
|
else if (st === 'conditional') cond++;
|
||||||
|
else other++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const elTotal = document.getElementById('te-total-count');
|
||||||
|
if (elTotal) elTotal.textContent = total;
|
||||||
|
const elAll = document.getElementById('te-count-all');
|
||||||
|
if (elAll) elAll.textContent = total;
|
||||||
|
const elAuth = document.getElementById('te-count-authorized');
|
||||||
|
if (elAuth) elAuth.textContent = auth;
|
||||||
|
const elCond = document.getElementById('te-count-conditional');
|
||||||
|
if (elCond) elCond.textContent = cond;
|
||||||
|
const elOther = document.getElementById('te-count-other');
|
||||||
|
if (elOther) elOther.textContent = other;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMapLayers() {
|
||||||
|
if (!teMap) return;
|
||||||
|
|
||||||
|
const geojsonData = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: teFeatures
|
||||||
|
};
|
||||||
|
|
||||||
|
teMap.addSource('te-structures', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geojsonData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outer glow layer
|
||||||
|
teMap.addLayer({
|
||||||
|
id: 'te-structures-glow',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'te-structures',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 10, 8, 16, 18],
|
||||||
|
'circle-color': [
|
||||||
|
'match',
|
||||||
|
['get', 'passage_status'],
|
||||||
|
'authorized', STATUS_COLORS.authorized,
|
||||||
|
'conditional', STATUS_COLORS.conditional,
|
||||||
|
'prior_study_required', STATUS_COLORS.prior_study_required,
|
||||||
|
'forbidden', STATUS_COLORS.forbidden,
|
||||||
|
STATUS_COLORS.not_evaluated
|
||||||
|
],
|
||||||
|
'circle-opacity': 0.25,
|
||||||
|
'circle-blur': 0.4
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main circle points layer
|
||||||
|
teMap.addLayer({
|
||||||
|
id: 'te-structures-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'te-structures',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 10, 6, 16, 12],
|
||||||
|
'circle-color': [
|
||||||
|
'match',
|
||||||
|
['get', 'passage_status'],
|
||||||
|
'authorized', STATUS_COLORS.authorized,
|
||||||
|
'conditional', STATUS_COLORS.conditional,
|
||||||
|
'prior_study_required', STATUS_COLORS.prior_study_required,
|
||||||
|
'forbidden', STATUS_COLORS.forbidden,
|
||||||
|
STATUS_COLORS.not_evaluated
|
||||||
|
],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
'circle-opacity': 0.95
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cursor pointer
|
||||||
|
teMap.on('mouseenter', 'te-structures-circles', () => {
|
||||||
|
teMap.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
teMap.on('mouseleave', 'te-structures-circles', () => {
|
||||||
|
teMap.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler with multi-structure grouping
|
||||||
|
teMap.on('click', 'te-structures-circles', (e) => {
|
||||||
|
if (!e.features || !e.features.length) return;
|
||||||
|
|
||||||
|
// Query features in small buffer for stacked structures
|
||||||
|
const bbox = [
|
||||||
|
[e.point.x - 8, e.point.y - 8],
|
||||||
|
[e.point.x + 8, e.point.y + 8]
|
||||||
|
];
|
||||||
|
const clickedFeatures = teMap.queryRenderedFeatures(bbox, { layers: ['te-structures-circles'] });
|
||||||
|
|
||||||
|
// De-duplicate features by structure_id
|
||||||
|
const uniqueFeatures = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
(clickedFeatures.length ? clickedFeatures : e.features).forEach(f => {
|
||||||
|
const sid = f.properties.structure_id;
|
||||||
|
if (!seenIds.has(sid)) {
|
||||||
|
seenIds.add(sid);
|
||||||
|
uniqueFeatures.push(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
openPopupForFeatures(uniqueFeatures, e.lngLat);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPopupForFeatures(features, lngLat) {
|
||||||
|
if (currentPopup) currentPopup.remove();
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (features.length === 1) {
|
||||||
|
html = buildSingleStructurePopupHtml(features[0].properties);
|
||||||
|
} else {
|
||||||
|
html = buildMultiStructurePopupHtml(features);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPopup = new maplibregl.Popup({
|
||||||
|
className: 'te-popup',
|
||||||
|
closeButton: true,
|
||||||
|
closeOnClick: true,
|
||||||
|
maxWidth: '360px',
|
||||||
|
offset: 12
|
||||||
|
})
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML(html)
|
||||||
|
.addTo(teMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleStructurePopupHtml(p) {
|
||||||
|
const statusBadge = getStatusBadgeHtml(p.passage_status, p.passage_status_display);
|
||||||
|
|
||||||
|
let metricsHtml = '<div class="row g-2 mb-2">';
|
||||||
|
metricsHtml += `
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="te-metric-box">
|
||||||
|
<div class="small text-muted" style="font-size: 0.7rem;">Poids max autorisé</div>
|
||||||
|
<div class="fw-bold text-dark">${p.max_authorized_weight_tonnes ? p.max_authorized_weight_tonnes + ' t' : (p.max_calculated_weight_tonnes ? p.max_calculated_weight_tonnes + ' t (calc)' : '-')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="te-metric-box">
|
||||||
|
<div class="small text-muted" style="font-size: 0.7rem;">Classe de pont (BKL)</div>
|
||||||
|
<div class="fw-bold text-dark text-truncate" title="${p.bridge_class || '-'}">${p.bridge_class || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (p.usable_width_m || p.clearance_heights_str) {
|
||||||
|
metricsHtml += `
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="te-metric-box">
|
||||||
|
<div class="small text-muted" style="font-size: 0.7rem;">Largeur utile</div>
|
||||||
|
<div class="fw-bold text-dark">${p.usable_width_m ? p.usable_width_m + ' m' : '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="te-metric-box">
|
||||||
|
<div class="small text-muted" style="font-size: 0.7rem;">Hauteurs (G/C/D)</div>
|
||||||
|
<div class="fw-bold text-dark text-truncate" title="${p.clearance_heights_str || '-'}">${p.clearance_heights_str || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
metricsHtml += '</div>';
|
||||||
|
|
||||||
|
// Restrictions badges
|
||||||
|
let restrictionsBadges = '';
|
||||||
|
if (p.max_speed_kmh) {
|
||||||
|
restrictionsBadges += `<span class="badge bg-warning text-dark me-1 mb-1"><i class="bi bi-speedometer2 me-1"></i>Max ${p.max_speed_kmh} km/h</span>`;
|
||||||
|
}
|
||||||
|
if (p.is_braking_forbidden) {
|
||||||
|
restrictionsBadges += `<span class="badge bg-danger me-1 mb-1"><i class="bi bi-slash-circle me-1"></i>Freinage interdit</span>`;
|
||||||
|
}
|
||||||
|
if (p.is_isolated_crossing_only) {
|
||||||
|
restrictionsBadges += `<span class="badge bg-info text-dark me-1 mb-1"><i class="bi bi-person-slash me-1"></i>Convoi seul</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prescriptions excerpt
|
||||||
|
let prescText = p.prescriptions_fr || p.prescriptions_nl || '';
|
||||||
|
let prescHtml = '';
|
||||||
|
if (prescText) {
|
||||||
|
let shortPresc = prescText.length > 130 ? prescText.substring(0, 130) + '...' : prescText;
|
||||||
|
prescHtml = `
|
||||||
|
<div class="alert alert-light border p-2 mb-2 text-start" style="font-size: 0.75rem; max-height: 80px; overflow-y: auto;">
|
||||||
|
<div class="fw-bold text-secondary mb-1"><i class="bi bi-card-text me-1"></i>Prescriptions :</div>
|
||||||
|
${shortPresc}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card border-0 shadow-none">
|
||||||
|
<div class="card-header bg-light border-bottom p-3 text-start">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="badge bg-secondary">${p.structure_code}</span>
|
||||||
|
${p.fod_vnr ? `<span class="small text-muted">FOD n°${p.fod_vnr}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="fw-bold text-dark" style="font-size: 0.95rem; line-height: 1.2;">
|
||||||
|
${p.name || p.name_fr || p.structure_code}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">${statusBadge}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
${metricsHtml}
|
||||||
|
${restrictionsBadges ? `<div class="mb-2 text-start">${restrictionsBadges}</div>` : ''}
|
||||||
|
${prescHtml}
|
||||||
|
<a href="${p.detail_url}" class="btn btn-primary btn-sm w-100 rounded-pill fw-semibold mt-1">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Ouvrir la fiche de l'ouvrage" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMultiStructurePopupHtml(features) {
|
||||||
|
let itemsHtml = '';
|
||||||
|
features.forEach((f, idx) => {
|
||||||
|
const p = f.properties;
|
||||||
|
const statusBadge = getStatusBadgeHtml(p.passage_status, p.passage_status_display);
|
||||||
|
itemsHtml += `
|
||||||
|
<div class="border-bottom pb-2 mb-2 text-start ${idx > 0 ? 'pt-2' : ''}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="badge bg-secondary me-1">${p.structure_code}</span>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<div class="fw-bold text-dark small mt-1">${p.name || p.name_fr}</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<span class="small text-muted">Poids: <strong>${p.max_authorized_weight_tonnes ? p.max_authorized_weight_tonnes + 't' : '-'}</strong></span>
|
||||||
|
<a href="${p.detail_url}" class="btn btn-sm btn-outline-primary py-0 px-2 rounded-pill" style="font-size: 0.75rem;">
|
||||||
|
{% translate "Détail" %} <i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card border-0 shadow-none">
|
||||||
|
<div class="card-header bg-light border-bottom p-2 px-3 text-start">
|
||||||
|
<div class="fw-bold text-dark" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-layers-fill text-primary me-1"></i>${features.length} {% translate "ouvrages à cet endroit" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3" style="max-height: 280px; overflow-y: auto;">
|
||||||
|
${itemsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeHtml(status, display) {
|
||||||
|
let colorClass = 'bg-secondary';
|
||||||
|
let icon = 'bi-question-circle';
|
||||||
|
if (status === 'authorized') {
|
||||||
|
colorClass = 'bg-success';
|
||||||
|
icon = 'bi-check-circle-fill';
|
||||||
|
} else if (status === 'conditional') {
|
||||||
|
colorClass = 'bg-warning text-dark';
|
||||||
|
icon = 'bi-exclamation-triangle-fill';
|
||||||
|
} else if (status === 'prior_study_required') {
|
||||||
|
colorClass = 'bg-orange text-white' ;
|
||||||
|
icon = 'bi-journal-text';
|
||||||
|
} else if (status === 'forbidden') {
|
||||||
|
colorClass = 'bg-danger';
|
||||||
|
icon = 'bi-slash-circle-fill';
|
||||||
|
}
|
||||||
|
return `<span class="badge ${colorClass} py-1 px-2"><i class="bi ${icon} me-1"></i>${display || status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearchAutocomplete() {
|
||||||
|
const input = document.getElementById('te-search-input');
|
||||||
|
const clearBtn = document.getElementById('te-search-clear');
|
||||||
|
const dropdown = document.getElementById('te-autocomplete-dropdown');
|
||||||
|
if (!input || !dropdown) return;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
if (clearBtn) {
|
||||||
|
if (query.length > 0) clearBtn.classList.remove('d-none');
|
||||||
|
else clearBtn.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length < 1) {
|
||||||
|
dropdown.classList.add('d-none');
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter features
|
||||||
|
const matches = teFeatures.filter(f => {
|
||||||
|
if (activeFilter !== 'all') {
|
||||||
|
const st = f.properties.passage_status;
|
||||||
|
if (activeFilter === 'authorized' && st !== 'authorized') return false;
|
||||||
|
if (activeFilter === 'conditional' && st !== 'conditional') return false;
|
||||||
|
if (activeFilter === 'other' && (st === 'authorized' || st === 'conditional')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = f.properties;
|
||||||
|
const code = (p.structure_code || '').toLowerCase();
|
||||||
|
const nameFr = (p.name_fr || '').toLowerCase();
|
||||||
|
const nameNl = (p.name_nl || '').toLowerCase();
|
||||||
|
const vnr = p.fod_vnr ? String(p.fod_vnr) : '';
|
||||||
|
const bkl = (p.bridge_class || '').toLowerCase();
|
||||||
|
|
||||||
|
return code.includes(query) || nameFr.includes(query) || nameNl.includes(query) || vnr.includes(query) || bkl.includes(query);
|
||||||
|
}).slice(0, 8);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
dropdown.innerHTML = `<div class="p-3 text-muted small text-center">{% translate "Aucun ouvrage correspondant trouvé" %}</div>`;
|
||||||
|
dropdown.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
matches.forEach(f => {
|
||||||
|
const p = f.properties;
|
||||||
|
const statusBadge = getStatusBadgeHtml(p.passage_status, p.passage_status_display);
|
||||||
|
html += `
|
||||||
|
<button type="button" class="list-group-item list-group-item-action te-autocomplete-item p-2 px-3 border-bottom text-start" data-structure-id="${p.structure_id}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="badge bg-secondary">${p.structure_code}</span>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<div class="fw-bold text-dark small mt-1 text-truncate">${p.name || p.name_fr}</div>
|
||||||
|
<div class="small text-muted d-flex justify-content-between mt-1" style="font-size: 0.75rem;">
|
||||||
|
<span>${p.fod_vnr ? 'FOD: ' + p.fod_vnr : ''}</span>
|
||||||
|
<span>Poids: ${p.max_authorized_weight_tonnes ? p.max_authorized_weight_tonnes + 't' : (p.max_calculated_weight_tonnes ? p.max_calculated_weight_tonnes + 't' : '-')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.innerHTML = html;
|
||||||
|
dropdown.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Add click listeners to items
|
||||||
|
dropdown.querySelectorAll('.te-autocomplete-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const sid = parseInt(item.getAttribute('data-structure-id'));
|
||||||
|
const selected = teFeatures.find(f => f.properties.structure_id === sid);
|
||||||
|
if (selected && teMap) {
|
||||||
|
const coords = selected.geometry.coordinates;
|
||||||
|
teMap.flyTo({
|
||||||
|
center: coords,
|
||||||
|
zoom: 16,
|
||||||
|
speed: 1.2
|
||||||
|
});
|
||||||
|
openPopupForFeatures([selected], coords);
|
||||||
|
}
|
||||||
|
dropdown.classList.add('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
input.value = '';
|
||||||
|
clearBtn.classList.add('d-none');
|
||||||
|
dropdown.classList.add('d-none');
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
dropdown.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStatusFilters() {
|
||||||
|
const filterBtns = document.querySelectorAll('.te-filter-btn');
|
||||||
|
filterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
activeFilter = btn.getAttribute('data-status');
|
||||||
|
|
||||||
|
if (!teMap || !teMap.getLayer('te-structures-circles')) return;
|
||||||
|
|
||||||
|
let filterExpr = null;
|
||||||
|
if (activeFilter === 'authorized') {
|
||||||
|
filterExpr = ['==', ['get', 'passage_status'], 'authorized'];
|
||||||
|
} else if (activeFilter === 'conditional') {
|
||||||
|
filterExpr = ['==', ['get', 'passage_status'], 'conditional'];
|
||||||
|
} else if (activeFilter === 'other') {
|
||||||
|
filterExpr = ['all',
|
||||||
|
['!=', ['get', 'passage_status'], 'authorized'],
|
||||||
|
['!=', ['get', 'passage_status'], 'conditional']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
teMap.setFilter('te-structures-circles', filterExpr);
|
||||||
|
teMap.setFilter('te-structures-glow', filterExpr);
|
||||||
|
|
||||||
|
if (currentPopup) currentPopup.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupResetButton() {
|
||||||
|
const resetBtn = document.getElementById('te-btn-reset-view');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
if (teMap) {
|
||||||
|
teMap.flyTo({
|
||||||
|
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
|
||||||
|
zoom: window.MAP_DEFAULT_ZOOM || 11.5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-init on DOMContentLoaded or immediate if DOM already ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initExceptionalTransportWidget);
|
||||||
|
} else {
|
||||||
|
initExceptionalTransportWidget();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Loading…
Reference in a new issue