feat: add metro structure types and filtering UI to exceptional transport widget
This commit is contained in:
parent
0fa237564c
commit
f23107e749
4 changed files with 228 additions and 33 deletions
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -17,13 +17,15 @@ private_storage = PrivateMediaStorage()
|
||||||
|
|
||||||
|
|
||||||
STRUCTURE_TYPE_CHOICES = (
|
STRUCTURE_TYPE_CHOICES = (
|
||||||
('tunnel', 'Tunnnel'),
|
('tunnel', _('Tunnel')),
|
||||||
('bridge', 'Pont'),
|
('bridge', _('Pont')),
|
||||||
('viaduct', 'Viaduc'),
|
('viaduct', _('Viaduc')),
|
||||||
('overpass', 'Passerelle'),
|
('overpass', _('Passerelle')),
|
||||||
('underpass', 'Passage souterrain'),
|
('underpass', _('Passage souterrain')),
|
||||||
('gallery', 'Galerie technique'),
|
('gallery', _('Galerie technique')),
|
||||||
('atypical', 'Ouvrage atypique'),
|
('atypical', _('Ouvrage atypique')),
|
||||||
|
('metro_station', _('Station de métro')),
|
||||||
|
('metro_tunnel', _('Tunnel de métro')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def structures_index(request):
|
||||||
selected_locations_ids = draft_data.get('locations', [])
|
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 = (
|
thematic_choices_qs = (
|
||||||
UserThematics.objects
|
UserThematics.objects
|
||||||
.filter(user_config=user_config, can_view_assets=True)
|
.filter(user_config=user_config, can_view_assets=True)
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="card-body p-3 p-md-4">
|
<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">
|
<div class="row g-2 align-items-center mb-3">
|
||||||
<!-- Search Autocomplete -->
|
<!-- 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">
|
<div class="input-group shadow-sm rounded-pill overflow-hidden border">
|
||||||
<span class="input-group-text bg-white border-0 ps-3 text-muted">
|
<span class="input-group-text bg-white border-0 ps-3 text-muted">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
|
|
@ -55,9 +55,42 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Status Filter Chips -->
|
||||||
<div class="col-12 col-lg-7">
|
<div class="d-flex flex-wrap gap-1" id="te-status-filters">
|
||||||
<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">
|
<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>)
|
{% translate "Tous" %} (<span id="te-count-all">0</span>)
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -76,6 +109,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Map Container -->
|
<!-- Map Container -->
|
||||||
<div class="position-relative rounded-4 overflow-hidden border shadow-inner" style="height: 550px;">
|
<div class="position-relative rounded-4 overflow-hidden border shadow-inner" style="height: 550px;">
|
||||||
|
|
@ -157,6 +191,14 @@
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|
||||||
<!-- Widget Logic -->
|
<!-- Widget Logic -->
|
||||||
|
|
@ -165,6 +207,9 @@
|
||||||
const GEOJSON_URL = "{% url 'assets:structures_exceptional_transport_geojson' %}";
|
const GEOJSON_URL = "{% url 'assets:structures_exceptional_transport_geojson' %}";
|
||||||
let teMap = null;
|
let teMap = null;
|
||||||
let teFeatures = [];
|
let teFeatures = [];
|
||||||
|
let visibleFeatures = [];
|
||||||
|
let availableTypes = [];
|
||||||
|
let selectedTypeCodes = new Set();
|
||||||
let activeFilter = 'all';
|
let activeFilter = 'all';
|
||||||
let currentPopup = null;
|
let currentPopup = null;
|
||||||
|
|
||||||
|
|
@ -198,6 +243,11 @@
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
teFeatures = (data && data.features) ? data.features : [];
|
teFeatures = (data && data.features) ? data.features : [];
|
||||||
|
|
||||||
|
// Discover available structure types & select all except metro tunnels by default
|
||||||
|
initStructureTypes();
|
||||||
|
updateVisibleFeatures();
|
||||||
|
|
||||||
|
setupTypeFilterMenu();
|
||||||
updateCounts();
|
updateCounts();
|
||||||
renderMapLayers();
|
renderMapLayers();
|
||||||
setupSearchAutocomplete();
|
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() {
|
function updateCounts() {
|
||||||
let total = teFeatures.length;
|
let total = visibleFeatures.length;
|
||||||
let auth = 0, cond = 0, notEval = 0, restricted = 0;
|
let auth = 0, cond = 0, notEval = 0, restricted = 0;
|
||||||
|
|
||||||
teFeatures.forEach(f => {
|
visibleFeatures.forEach(f => {
|
||||||
const st = f.properties.passage_status;
|
const st = f.properties.passage_status;
|
||||||
if (st === 'authorized') auth++;
|
if (st === 'authorized') auth++;
|
||||||
else if (st === 'conditional') cond++;
|
else if (st === 'conditional') cond++;
|
||||||
|
|
@ -247,7 +422,7 @@
|
||||||
|
|
||||||
const geojsonData = {
|
const geojsonData = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: teFeatures
|
features: visibleFeatures
|
||||||
};
|
};
|
||||||
|
|
||||||
teMap.addSource('te-structures', {
|
teMap.addSource('te-structures', {
|
||||||
|
|
@ -551,7 +726,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter features
|
// Filter features
|
||||||
const matches = teFeatures.filter(f => {
|
const matches = visibleFeatures.filter(f => {
|
||||||
const p = f.properties;
|
const p = f.properties;
|
||||||
const st = p.passage_status;
|
const st = p.passage_status;
|
||||||
|
|
||||||
|
|
@ -608,7 +783,7 @@
|
||||||
dropdown.querySelectorAll('.te-autocomplete-item').forEach(item => {
|
dropdown.querySelectorAll('.te-autocomplete-item').forEach(item => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
const sid = parseInt(item.getAttribute('data-structure-id'));
|
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) {
|
if (selected && teMap) {
|
||||||
const coords = selected.geometry.coordinates;
|
const coords = selected.geometry.coordinates;
|
||||||
teMap.flyTo({
|
teMap.flyTo({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue