feat: add metro structure types and filtering UI to exceptional transport widget

This commit is contained in:
kdeterme 2026-09-21 14:42:23 +02:00
parent 0fa237564c
commit f23107e749
4 changed files with 228 additions and 33 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-21 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0026_add_naturetree_specific_fields'),
]
operations = [
migrations.AlterField(
model_name='structure',
name='structure_type',
field=models.CharField(blank=True, choices=[('tunnel', 'Tunnel'), ('bridge', 'Pont'), ('viaduct', 'Viaduc'), ('overpass', 'Passerelle'), ('underpass', 'Passage souterrain'), ('gallery', 'Galerie technique'), ('atypical', 'Ouvrage atypique'), ('metro_station', 'Station de métro'), ('metro_tunnel', 'Tunnel de métro')], max_length=200, null=True),
),
]

View file

@ -17,13 +17,15 @@ private_storage = PrivateMediaStorage()
STRUCTURE_TYPE_CHOICES = (
('tunnel', 'Tunnnel'),
('bridge', 'Pont'),
('viaduct', 'Viaduc'),
('overpass', 'Passerelle'),
('underpass', 'Passage souterrain'),
('gallery', 'Galerie technique'),
('atypical', 'Ouvrage atypique'),
('tunnel', _('Tunnel')),
('bridge', _('Pont')),
('viaduct', _('Viaduc')),
('overpass', _('Passerelle')),
('underpass', _('Passage souterrain')),
('gallery', _('Galerie technique')),
('atypical', _('Ouvrage atypique')),
('metro_station', _('Station de métro')),
('metro_tunnel', _('Tunnel de métro')),
)

View file

@ -61,7 +61,7 @@ def structures_index(request):
selected_locations_ids = draft_data.get('locations', [])
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=user_config, can_view_assets=True)

View file

@ -31,10 +31,10 @@
<!-- Body -->
<div class="card-body p-3 p-md-4">
<!-- Controls Bar: Search & Status Filters -->
<!-- Controls Bar: Search, Type Filter & Status Filters -->
<div class="row g-2 align-items-center mb-3">
<!-- Search Autocomplete -->
<div class="col-12 col-lg-5 position-relative">
<div class="col-12 col-xl-4 col-lg-5 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>
@ -55,9 +55,42 @@
</div>
</div>
<!-- Filters: Type Dropdown & Status Filter Chips -->
<div class="col-12 col-xl-8 col-lg-7">
<div class="d-flex flex-wrap gap-2 justify-content-lg-end align-items-center" id="te-controls-group">
<!-- Type Filter Dropdown -->
<div class="dropdown te-type-dropdown">
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3 py-1 dropdown-toggle d-flex align-items-center gap-1 shadow-sm"
type="button"
id="te-type-filter-dropdown-btn"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
title="{% translate 'Filtrer par types d\'ouvrage' %}">
<i class="bi bi-funnel-fill text-primary"></i>
<span>{% translate "Types d'ouvrage" %}</span>
<span class="badge bg-primary rounded-pill ms-1" id="te-types-count-badge">0/0</span>
</button>
<div class="dropdown-menu dropdown-menu-end shadow-lg p-2 border-0 rounded-3 mt-1"
aria-labelledby="te-type-filter-dropdown-btn"
id="te-type-dropdown-menu"
style="min-width: 280px; max-height: 420px; overflow-y: auto; z-index: 1060;">
<div class="d-flex justify-content-between align-items-center px-2 py-1 mb-2 border-bottom pb-2">
<span class="fw-bold small text-secondary">{% translate "Types d'ouvrage" %}</span>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-link btn-sm p-0 text-primary text-decoration-none fw-semibold" id="te-types-select-all" style="font-size: 0.75rem;">{% translate "Tous" %}</button>
<span class="text-muted small">|</span>
<button type="button" class="btn btn-link btn-sm p-0 text-muted text-decoration-none" id="te-types-select-none" style="font-size: 0.75rem;">{% translate "Aucun" %}</button>
</div>
</div>
<div id="te-type-checkbox-list" class="d-flex flex-column gap-1">
<!-- Dynamically populated from GeoJSON -->
</div>
</div>
</div>
<!-- Status Filter Chips -->
<div class="col-12 col-lg-7">
<div class="d-flex flex-wrap gap-1 justify-content-lg-end" id="te-status-filters">
<div class="d-flex flex-wrap gap-1" 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>
@ -76,6 +109,7 @@
</div>
</div>
</div>
</div>
<!-- Map Container -->
<div class="position-relative rounded-4 overflow-hidden border shadow-inner" style="height: 550px;">
@ -157,6 +191,14 @@
padding: 6px 10px;
text-align: center;
}
.te-type-item {
cursor: pointer;
transition: background-color 0.15s ease-in-out;
user-select: none;
}
.te-type-item:hover {
background-color: #f1f8ff;
}
</style>
<!-- Widget Logic -->
@ -165,6 +207,9 @@
const GEOJSON_URL = "{% url 'assets:structures_exceptional_transport_geojson' %}";
let teMap = null;
let teFeatures = [];
let visibleFeatures = [];
let availableTypes = [];
let selectedTypeCodes = new Set();
let activeFilter = 'all';
let currentPopup = null;
@ -198,6 +243,11 @@
const data = await response.json();
teFeatures = (data && data.features) ? data.features : [];
// Discover available structure types & select all except metro tunnels by default
initStructureTypes();
updateVisibleFeatures();
setupTypeFilterMenu();
updateCounts();
renderMapLayers();
setupSearchAutocomplete();
@ -216,11 +266,136 @@
});
}
function initStructureTypes() {
const typeMap = new Map();
teFeatures.forEach(f => {
const code = f.properties.structure_type_code || 'other';
const label = f.properties.structure_type || (code === 'other' ? "{% translate 'Autre' %}" : code);
if (!typeMap.has(code)) {
typeMap.set(code, { code: code, label: label, count: 0 });
}
typeMap.get(code).count++;
});
availableTypes = Array.from(typeMap.values()).sort((a, b) => a.label.localeCompare(b.label, 'fr'));
// Default: include all structure types EXCEPT metro tunnels ('metro_tunnel')
selectedTypeCodes = new Set();
availableTypes.forEach(t => {
if (t.code !== 'metro_tunnel') {
selectedTypeCodes.add(t.code);
}
});
}
function updateVisibleFeatures() {
visibleFeatures = teFeatures.filter(f => {
const code = f.properties.structure_type_code || 'other';
return selectedTypeCodes.has(code);
});
}
function setupTypeFilterMenu() {
const listContainer = document.getElementById('te-type-checkbox-list');
if (!listContainer) return;
listContainer.innerHTML = '';
availableTypes.forEach(t => {
const isChecked = selectedTypeCodes.has(t.code);
const item = document.createElement('div');
item.className = 'dropdown-item d-flex align-items-center justify-content-between rounded-2 px-2 py-1 te-type-item';
item.innerHTML = `
<div class="form-check mb-0 d-flex align-items-center gap-2">
<input class="form-check-input te-type-checkbox" type="checkbox" value="${t.code}" id="te-type-cb-${t.code}" ${isChecked ? 'checked' : ''} style="cursor: pointer;">
<label class="form-check-label small mb-0 ${t.code === 'metro_tunnel' ? 'text-muted' : 'text-dark'}" for="te-type-cb-${t.code}" style="cursor: pointer;">
${t.label}
</label>
</div>
<span class="badge bg-light text-secondary border rounded-pill ms-2" style="font-size: 0.7rem;">${t.count}</span>
`;
const cb = item.querySelector('.te-type-checkbox');
cb.addEventListener('change', () => {
if (cb.checked) {
selectedTypeCodes.add(t.code);
} else {
selectedTypeCodes.delete(t.code);
}
onTypeFilterChanged();
});
item.addEventListener('click', (e) => {
if (e.target !== cb && e.target.tagName !== 'LABEL') {
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
}
});
listContainer.appendChild(item);
});
// Quick action: Select All
const btnAll = document.getElementById('te-types-select-all');
if (btnAll) {
btnAll.addEventListener('click', (e) => {
e.preventDefault();
availableTypes.forEach(t => selectedTypeCodes.add(t.code));
document.querySelectorAll('.te-type-checkbox').forEach(cb => cb.checked = true);
onTypeFilterChanged();
});
}
// Quick action: Select None
const btnNone = document.getElementById('te-types-select-none');
if (btnNone) {
btnNone.addEventListener('click', (e) => {
e.preventDefault();
selectedTypeCodes.clear();
document.querySelectorAll('.te-type-checkbox').forEach(cb => cb.checked = false);
onTypeFilterChanged();
});
}
updateTypeFilterBadge();
}
function updateTypeFilterBadge() {
const badge = document.getElementById('te-types-count-badge');
if (badge) {
badge.textContent = `${selectedTypeCodes.size}/${availableTypes.length}`;
if (selectedTypeCodes.size === availableTypes.length) {
badge.className = 'badge bg-primary rounded-pill ms-1';
} else if (selectedTypeCodes.size === 0) {
badge.className = 'badge bg-danger rounded-pill ms-1';
} else {
badge.className = 'badge bg-primary rounded-pill ms-1';
}
}
}
function onTypeFilterChanged() {
updateVisibleFeatures();
updateTypeFilterBadge();
if (teMap && teMap.getSource('te-structures')) {
teMap.getSource('te-structures').setData({
type: 'FeatureCollection',
features: visibleFeatures
});
}
updateCounts();
if (currentPopup) {
currentPopup.remove();
}
}
function updateCounts() {
let total = teFeatures.length;
let total = visibleFeatures.length;
let auth = 0, cond = 0, notEval = 0, restricted = 0;
teFeatures.forEach(f => {
visibleFeatures.forEach(f => {
const st = f.properties.passage_status;
if (st === 'authorized') auth++;
else if (st === 'conditional') cond++;
@ -247,7 +422,7 @@
const geojsonData = {
type: 'FeatureCollection',
features: teFeatures
features: visibleFeatures
};
teMap.addSource('te-structures', {
@ -551,7 +726,7 @@
}
// Filter features
const matches = teFeatures.filter(f => {
const matches = visibleFeatures.filter(f => {
const p = f.properties;
const st = p.passage_status;
@ -608,7 +783,7 @@
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);
const selected = visibleFeatures.find(f => f.properties.structure_id === sid);
if (selected && teMap) {
const coords = selected.geometry.coordinates;
teMap.flyTo({