loko/loko/assets/static/assets/unified_index.js

3953 lines
162 KiB
JavaScript

/**
* unified_index.js
* Handles the unified assets+locations index page.
* Expects the following globals injected by the template:
* THEMATIC_CONFIG, CONDITIONAL_FILTER_CONFIG, THEMATIC_CATEGORIES, SELECTED_THEMATICS,
* PREVENTIVE_DRAFT_ID, LOCKED_THEMATIC_CODE, PREVENTIVE_ELEMENT_TYPE,
* SELECTED_ASSETS, SELECTED_LOCATION_IDS, LINK_ASSETS_URL,
* ASSETS_INDEX_PREFS, DEFAULT_THEMATIC_CODE, SAVE_ASSETS_PREFS_URL
*/
'use strict';
// ─── State ────────────────────────────────────────────────────────────────────
var allFeatures = []; // merged features from all loaded thematics
var filteredFeatures = []; // after type+search+status+category filters
var activeThematics = new Set(SELECTED_THEMATICS || []);
var activeCategories = new Set(SELECTED_CATEGORIES || []);
var activeModels = new Set();
var modelFilterCollapsed = true;
var activeConditionalFilters = {};
var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
var searchTerms = [];
var searchMode = 'and';
var shouldFitSearch = false;
var searchIsGlobal = false;
var selectedIds = new Set(); // ids of checked rows
var loadingCount = 0;
var categoryLabelByThematicAndId = {};
var categoryValueByThematicAndLabel = {};
var modelsByCategoryKey = {};
var pendingModelFetches = {};
// GeoJSON caches per thematic { thematic_code: { locations: [], assets: [] } }
var geojsonCache = {};
var locationsFetchSignatures = {};
var assetsFetchSignatures = {};
// Assets: thematics that always load all assets (small datasets)
const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'parking']);
const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
// MapLibre sources
var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] };
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
var currentFetchAbortController = null;
var assetClickPopup = null;
var zoomFocusMarker = null;
var loadWarningVisible = false;
var unifiedAddAssetThematic = null;
var unifiedAddLocationThematic = null;
var unifiedLocationPickerMap = null;
var unifiedLocationPickerMarker = null;
var unifiedLocationPickerControl = null;
var panelTextSpriteMap = null;
var unifiedPanelOffsetRefreshTimer = null;
function shouldUseTrafficlightsLocationCreateFlow(thematic) {
return !!(thematic && thematic.code === 'trafficlights' && thematic.location_type);
}
function getUnifiedAddModalConfig() {
if (typeof ADD_MODAL_CONFIG !== 'object' || ADD_MODAL_CONFIG === null) {
return { asset_thematics: [], location_thematics: [] };
}
return ADD_MODAL_CONFIG;
}
function setModalAlert(alertId, message, variant) {
const alert = document.getElementById(alertId);
if (!alert) return;
if (!message) {
alert.classList.add('d-none');
alert.textContent = '';
return;
}
alert.className = `alert alert-${variant || 'info'}`;
alert.innerHTML = message;
alert.classList.remove('d-none');
}
function fillSelectWithOptions(select, items, placeholder) {
if (!select) return;
select.innerHTML = '';
if (placeholder) {
const option = document.createElement('option');
option.value = '';
option.textContent = placeholder;
select.appendChild(option);
}
(items || []).forEach(item => {
const option = document.createElement('option');
option.value = item.code;
option.textContent = item.name;
if (item.icon) {
option.dataset.icon = item.icon;
}
if (item.location_type) {
option.dataset.locationType = item.location_type;
}
if (item.location_label) {
option.dataset.locationLabel = item.location_label;
}
select.appendChild(option);
});
}
async function loadUnifiedAssetCategories(thematicCode) {
const categorySelect = document.getElementById('unifiedAddAssetCategory');
const modelSelect = document.getElementById('unifiedAddAssetModel');
if (!categorySelect || !modelSelect || !thematicCode) return;
categorySelect.disabled = true;
modelSelect.disabled = true;
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
categorySelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
try {
const response = await fetch(`/assets/${thematicCode}/categories/`);
const data = await response.json();
const categories = Array.isArray(data.categories) ? data.categories : [];
categorySelect.innerHTML = `<option value="">${gettext('Sélectionner une catégorie...')}</option>`;
categories.forEach(category => {
const option = document.createElement('option');
option.value = category.id;
option.textContent = category.name || category.code || category.id;
categorySelect.appendChild(option);
});
categorySelect.disabled = false;
if (!categories.length) {
categorySelect.innerHTML = `<option value="">${gettext('Aucune catégorie disponible')}</option>`;
}
} catch (error) {
console.error('Error loading asset categories:', error);
categorySelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
}
}
function getUnifiedAssetLocationConfig(thematicCode) {
const thematicConfig = THEMATIC_CONFIG && THEMATIC_CONFIG[thematicCode] ? THEMATIC_CONFIG[thematicCode] : null;
if (!thematicConfig || !thematicConfig.locations_geojson_url) {
return null;
}
return thematicConfig;
}
async function loadUnifiedAssetLocations(thematicCode) {
const group = document.getElementById('unifiedAddAssetLocationGroup');
const locationSelect = document.getElementById('unifiedAddAssetLocation');
const locationHelp = document.getElementById('unifiedAddAssetLocationHelp');
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
const locationConfig = getUnifiedAssetLocationConfig(thematicCode);
if (!group || !locationSelect) return;
if (!locationConfig) {
group.classList.add('d-none');
locationSelect.innerHTML = '<option value=""></option>';
locationSelect.disabled = true;
locationSelect.required = false;
if (submitBtn) submitBtn.disabled = false;
if (locationHelp) locationHelp.textContent = '';
return;
}
group.classList.remove('d-none');
locationSelect.disabled = true;
locationSelect.required = true;
locationSelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
if (locationHelp) {
locationHelp.textContent = gettext('Sélectionnez la localisation liée à ce nouvel asset.');
}
try {
const response = await fetch(locationConfig.locations_geojson_url);
const data = await response.json();
const features = Array.isArray(data.features) ? data.features : [];
locationSelect.innerHTML = `<option value="">${gettext('Sélectionner une localisation...')}</option>`;
features.forEach(feature => {
const props = feature.properties || {};
const locationId = feature.id ?? props.id ?? props.object_id ?? '';
if (locationId === '') return;
const code = props.code || '';
const name = props.name || props.name_fr || props.name_nl || '';
const label = [code, name].filter(Boolean).join(' - ') || String(locationId);
const option = document.createElement('option');
option.value = locationId;
option.textContent = label;
locationSelect.appendChild(option);
});
if (features.length > 0) {
locationSelect.disabled = false;
if (submitBtn) submitBtn.disabled = false;
} else {
locationSelect.innerHTML = `<option value="">${gettext('Aucune localisation disponible')}</option>`;
locationSelect.disabled = true;
if (submitBtn) submitBtn.disabled = true;
if (locationHelp) {
locationHelp.textContent = gettext('Aucune localisation disponible pour créer cet asset.');
}
}
} catch (error) {
console.error('Error loading asset locations:', error);
locationSelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
locationSelect.disabled = true;
if (submitBtn) submitBtn.disabled = true;
if (locationHelp) {
locationHelp.textContent = gettext('Impossible de charger les localisations pour cette thématique.');
}
}
}
async function loadUnifiedAssetModels(thematicCode, categoryId) {
const modelSelect = document.getElementById('unifiedAddAssetModel');
if (!modelSelect || !thematicCode) return;
modelSelect.disabled = true;
if (!categoryId) {
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
return;
}
modelSelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
try {
const response = await fetch(`/assets/${thematicCode}/category/${categoryId}/models/`);
const data = await response.json();
const models = Array.isArray(data.models) ? data.models : [];
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner un modèle...')}</option>`;
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name || model.code || model.id;
if (model.model_type) {
option.dataset.modelType = model.model_type;
}
modelSelect.appendChild(option);
});
if (models.length) {
modelSelect.disabled = false;
} else {
modelSelect.innerHTML = `<option value="">${gettext('Aucun modèle disponible')}</option>`;
}
} catch (error) {
console.error('Error loading asset models:', error);
modelSelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
}
}
function resetUnifiedAssetForm() {
const form = document.getElementById('unifiedAddAssetForm');
const categorySelect = document.getElementById('unifiedAddAssetCategory');
const modelSelect = document.getElementById('unifiedAddAssetModel');
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
if (form) form.reset();
if (categorySelect) {
categorySelect.innerHTML = `<option value="">${gettext('Sélectionner une catégorie...')}</option>`;
categorySelect.disabled = true;
}
if (modelSelect) {
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
modelSelect.disabled = true;
}
const locationGroup = document.getElementById('unifiedAddAssetLocationGroup');
const locationSelect = document.getElementById('unifiedAddAssetLocation');
const locationHelp = document.getElementById('unifiedAddAssetLocationHelp');
const codeGroup = document.getElementById('unifiedAddAssetCodeGroup');
const codeInput = document.getElementById('unifiedAddAssetCode');
const codeHelp = document.getElementById('unifiedAddAssetCodeHelp');
if (locationGroup) locationGroup.classList.add('d-none');
if (locationSelect) {
locationSelect.innerHTML = '<option value=""></option>';
locationSelect.disabled = true;
locationSelect.required = false;
}
if (locationHelp) locationHelp.textContent = '';
if (codeGroup) codeGroup.classList.remove('d-none');
if (codeInput) codeInput.required = true;
if (codeHelp) {
codeHelp.textContent = '';
codeHelp.classList.add('d-none');
}
if (submitBtn) submitBtn.disabled = false;
setModalAlert('unifiedAddAssetAlert', '', 'info');
}
async function openUnifiedAssetModal(thematicCode) {
const config = getUnifiedAddModalConfig();
const thematic = (config.asset_thematics || []).find(item => item.code === thematicCode);
if (!thematic) return;
unifiedAddAssetThematic = thematic;
const modalLabel = document.getElementById('unifiedAddAssetModalLabel');
if (modalLabel) modalLabel.textContent = `${gettext('Créer un asset')} - ${thematic.name}`;
resetUnifiedAssetForm();
const thematicInput = document.getElementById('unified-add-asset-thematic-code');
if (thematicInput) thematicInput.value = thematic.code;
await loadUnifiedAssetCategories(thematic.code);
const codeGroup = document.getElementById('unifiedAddAssetCodeGroup');
const codeInput = document.getElementById('unifiedAddAssetCode');
const codeHelp = document.getElementById('unifiedAddAssetCodeHelp');
if (shouldUseTrafficlightsLocationCreateFlow(thematic)) {
await loadUnifiedAssetLocations(thematic.code);
if (codeGroup) codeGroup.classList.add('d-none');
if (codeInput) codeInput.required = false;
if (codeHelp) {
codeHelp.textContent = gettext('Le code est généré automatiquement.');
codeHelp.classList.remove('d-none');
}
}
const modalEl = document.getElementById('unifiedAddAssetModal');
if (modalEl) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
function initUnifiedLocationPickerMap() {
const mapDiv = document.getElementById('locPickerMap');
if (!mapDiv || typeof maplibregl === 'undefined') return;
if (unifiedLocationPickerMap) {
unifiedLocationPickerMap.remove();
unifiedLocationPickerMap = null;
unifiedLocationPickerMarker = null;
unifiedLocationPickerControl = null;
}
const lonInput = document.getElementById('id_lon');
const latInput = document.getElementById('id_lat');
let center = [4.3572, 50.8477];
let zoom = 11;
if (lonInput && latInput && lonInput.value && latInput.value) {
center = [parseFloat(lonInput.value), parseFloat(latInput.value)];
zoom = 17;
}
unifiedLocationPickerMap = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center,
zoom,
minZoom: 9,
maxZoom: 24,
cooperativeGestures: true,
});
const setPoint = (lng, lat, fly = false) => {
if (lonInput) lonInput.value = String(lng);
if (latInput) latInput.value = String(lat);
if (!unifiedLocationPickerMarker) {
unifiedLocationPickerMarker = new maplibregl.Marker({ draggable: true })
.setLngLat([lng, lat])
.addTo(unifiedLocationPickerMap);
unifiedLocationPickerMarker.on('dragend', () => {
const ll = unifiedLocationPickerMarker.getLngLat();
if (lonInput) lonInput.value = String(ll.lng);
if (latInput) latInput.value = String(ll.lat);
});
} else {
unifiedLocationPickerMarker.setLngLat([lng, lat]);
}
if (fly) {
unifiedLocationPickerMap.flyTo({ center: [lng, lat], zoom: 17, duration: 600 });
}
};
if (typeof AddressSearchControl !== 'undefined' && typeof AUTOCOMPLETE_STREET_URL !== 'undefined') {
unifiedLocationPickerControl = new AddressSearchControl({
autocompleteUrl: AUTOCOMPLETE_STREET_URL,
lonInputId: 'id_lon',
latInputId: 'id_lat',
});
unifiedLocationPickerMap.addControl(unifiedLocationPickerControl, 'top-left');
unifiedLocationPickerMap.on('load', () => {
if (lonInput && latInput && lonInput.value && latInput.value && unifiedLocationPickerControl) {
unifiedLocationPickerControl.setMarker(parseFloat(lonInput.value), parseFloat(latInput.value), false);
}
unifiedLocationPickerMap.resize();
});
unifiedLocationPickerMap.on('click', (e) => {
if (unifiedLocationPickerControl) {
unifiedLocationPickerControl.setMarker(e.lngLat.lng, e.lngLat.lat);
}
});
} else {
unifiedLocationPickerMap.on('load', () => {
if (lonInput && latInput && lonInput.value && latInput.value) {
setPoint(parseFloat(lonInput.value), parseFloat(latInput.value), false);
}
unifiedLocationPickerMap.resize();
});
unifiedLocationPickerMap.on('click', (e) => {
setPoint(e.lngLat.lng, e.lngLat.lat);
});
}
document.getElementById('locPickerGeoBtn')?.addEventListener('click', (e) => {
e.preventDefault();
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
const lng = pos.coords.longitude;
const lat = pos.coords.latitude;
if (unifiedLocationPickerControl) {
unifiedLocationPickerControl.setMarker(lng, lat);
} else {
setPoint(lng, lat, true);
}
},
() => { },
{ enableHighAccuracy: true, timeout: 10000 }
);
});
document.getElementById('locPickerClearBtn')?.addEventListener('click', (e) => {
e.preventDefault();
if (lonInput) lonInput.value = '';
if (latInput) latInput.value = '';
if (unifiedLocationPickerControl) {
unifiedLocationPickerControl.clearMarker();
}
if (unifiedLocationPickerMarker) {
unifiedLocationPickerMarker.remove();
unifiedLocationPickerMarker = null;
}
unifiedLocationPickerMap.flyTo({ center: [4.3572, 50.8477], zoom: 11, duration: 600 });
});
}
async function loadUnifiedLocationForm(thematicCode, locationType) {
const fieldsContainer = document.getElementById('unifiedAddLocationFields');
const metaContainer = document.getElementById('unifiedAddLocationMeta');
if (!fieldsContainer || !metaContainer || !thematicCode || !locationType) return;
fieldsContainer.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
try {
const response = await fetch(`/assets/locations/${thematicCode}/${locationType}/form/`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || gettext('Erreur lors du chargement du formulaire.'));
}
fieldsContainer.innerHTML = data.html || '';
} catch (error) {
console.error('Error loading location form:', error);
fieldsContainer.innerHTML = `<div class="alert alert-danger mb-0">${gettext('Erreur lors du chargement du formulaire.')}</div>`;
}
}
function resetUnifiedLocationForm() {
const form = document.getElementById('unifiedAddLocationForm');
if (form) form.reset();
document.getElementById('unifiedAddLocationFields').innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
setModalAlert('unifiedAddLocationAlert', '', 'info');
}
async function openUnifiedLocationModal(thematicCode) {
const config = getUnifiedAddModalConfig();
const thematic = (config.location_thematics || []).find(item => item.code === thematicCode);
if (!thematic) return;
unifiedAddLocationThematic = thematic;
const modalLabel = document.getElementById('unifiedAddLocationModalLabel');
if (modalLabel) modalLabel.textContent = `${gettext('Créer une localisation')} - ${thematic.name}`;
const modalMeta = document.getElementById('unifiedAddLocationMeta');
if (modalMeta) modalMeta.textContent = `${gettext('Type')}: ${thematic.location_label}`;
resetUnifiedLocationForm();
const thematicCodeInput = document.getElementById('unified-add-location-thematic-code');
if (thematicCodeInput) thematicCodeInput.value = thematic.code;
const locationTypeInput = document.getElementById('unified-add-location-type');
if (locationTypeInput) locationTypeInput.value = thematic.location_type;
await loadUnifiedLocationForm(thematic.code, thematic.location_type);
const modalEl = document.getElementById('unifiedAddLocationModal');
if (!modalEl) return;
const onShown = () => {
initUnifiedLocationPickerMap();
bootstrap.Modal.getOrCreateInstance(modalEl).handleUpdate();
modalEl.removeEventListener('shown.bs.modal', onShown);
};
modalEl.addEventListener('shown.bs.modal', onShown);
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
// ─── Bootstrap-table columns ──────────────────────────────────────────────────
function zoomFormatter(value, row) {
if (!row.lon) return '';
return `<button class="btn btn-sm btn-light zoom-btn"
data-lon="${row.lon}"
data-lat="${row.lat}"
data-bs-toggle="tooltip"
title="${gettext('Zoom')}">
<i class="bi bi-geo-alt"></i>
</button>`;
}
function buildColumns() {
return [
{ field: 'check', checkbox: true, width: 36 },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
{
field: 'code', title: gettext('Code'), sortable: true,
formatter: function (v, row) {
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
return v || '-';
}
},
{ field: 'name', title: gettext('Nom'), sortable: true },
{ field: 'element_type', title: gettext('Type'), sortable: true },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
{
field: 'status', title: gettext('Statut'), sortable: true, visible: false,
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
},
{
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
formatter: function (v) {
const labels = {
'to_be_validated': gettext('À valider'),
'validated': gettext('Validé'),
'to_be_completed': gettext('À compléter'),
'rejected': gettext('Rejeté')
};
return labels[v] || v || '-';
}
},
];
}
const UNIFIED_PREFERENCE_COLUMN_FIELDS = [
'code',
'name',
'element_type',
'thematic_name',
'category',
'model',
'status',
'validation_status',
];
var isApplyingSavedColumnVisibility = false;
function getVisibleColumnsPreference() {
const table = $('#unified-table');
if (table.length && typeof table.bootstrapTable === 'function') {
const visibleColumns = table.bootstrapTable('getVisibleColumns') || [];
return visibleColumns
.map(col => col.field)
.filter(field => UNIFIED_PREFERENCE_COLUMN_FIELDS.includes(field));
}
return buildColumns()
.filter(col => UNIFIED_PREFERENCE_COLUMN_FIELDS.includes(col.field) && col.visible !== false)
.map(col => col.field);
}
function applyVisibleColumnsPreference(visibleColumns) {
if (!Array.isArray(visibleColumns)) return;
const table = $('#unified-table');
if (!table.length || typeof table.bootstrapTable !== 'function') return;
const wanted = new Set(visibleColumns.map(String));
isApplyingSavedColumnVisibility = true;
UNIFIED_PREFERENCE_COLUMN_FIELDS.forEach(field => {
if (wanted.has(field)) {
table.bootstrapTable('showColumn', field);
} else {
table.bootstrapTable('hideColumn', field);
}
});
isApplyingSavedColumnVisibility = false;
}
// ─── Preferences: save / restore ─────────────────────────────────────────────
/**
* Applies saved preferences (or the user's default thematic on first visit).
* Called inside document.ready BEFORE the initial refreshData() / showEmptyState().
* Skipped entirely in preventive-intervention mode or when the URL provides
* explicit thematic selection (GET params / locked_thematic_code).
*/
function applyInitialPreferences() {
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return;
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
const prefs = ASSETS_INDEX_PREFS;
// Thematics — only restore codes that are still accessible
if (Array.isArray(prefs.thematics) && prefs.thematics.length > 0) {
const valid = prefs.thematics.filter(code => code in THEMATIC_CONFIG);
if (valid.length > 0) {
activeThematics = new Set(valid);
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.checked = activeThematics.has(cb.value);
});
}
}
// Type filter
if (prefs.type_filter) {
currentTypeFilter = prefs.type_filter;
const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`);
if (rb) rb.checked = true;
}
// Include no geometry filter
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
if (includeNoGeomCheckbox && prefs.hasOwnProperty('include_no_geom')) {
includeNoGeomCheckbox.checked = prefs.include_no_geom;
}
// Status filters
if (Array.isArray(prefs.status_filters) && prefs.status_filters.length > 0) {
prefs.status_filters.forEach(status => {
const cb = document.querySelector(`[name="filter-status"][value="${status}"]`);
if (cb) cb.checked = true;
});
}
// Validation status filters
if (Array.isArray(prefs.validation_status_filters) && prefs.validation_status_filters.length > 0) {
prefs.validation_status_filters.forEach(status => {
const cb = document.querySelector(`[name="filter-validation-status"][value="${status}"]`);
if (cb) cb.checked = true;
});
}
if (Array.isArray(prefs.categories) && prefs.categories.length > 0) {
activeCategories = new Set(prefs.categories);
}
if (Array.isArray(prefs.models) && prefs.models.length > 0) {
activeModels = new Set(prefs.models);
}
if (prefs.conditional_filters && typeof prefs.conditional_filters === 'object') {
activeConditionalFilters = prefs.conditional_filters;
}
if (Array.isArray(prefs.visible_columns)) {
applyVisibleColumnsPreference(prefs.visible_columns);
}
// Map position — stored for use inside the mapLoaded handler
if (prefs.map_center && prefs.map_zoom) {
window._restoredMapCenter = prefs.map_center;
window._restoredMapZoom = prefs.map_zoom;
}
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
// First visit: pre-select the user's default thematic
activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.checked = cb.value === DEFAULT_THEMATIC_CODE;
});
}
}
var _prefsSaveTimer = null;
function schedulePreferencesSave() {
if (typeof SAVE_ASSETS_PREFS_URL === 'undefined') return;
clearTimeout(_prefsSaveTimer);
_prefsSaveTimer = setTimeout(savePreferences, 1000);
}
function savePreferences() {
if (typeof SAVE_ASSETS_PREFS_URL === 'undefined') return;
const statusFilters = Array.from(
document.querySelectorAll('[name="filter-status"]:checked')
).map(cb => cb.value);
const validationStatusFilters = Array.from(
document.querySelectorAll('[name="filter-validation-status"]:checked')
).map(cb => cb.value);
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const prefs = {
thematics: Array.from(activeThematics),
categories: Array.from(activeCategories),
models: Array.from(activeModels),
conditional_filters: activeConditionalFilters,
visible_columns: getVisibleColumnsPreference(),
type_filter: currentTypeFilter,
status_filters: statusFilters,
validation_status_filters: validationStatusFilters,
include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
};
fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify(prefs),
keepalive: true,
}).catch(() => { });
}
// ─── Table init ───────────────────────────────────────────────────────────────
$(document).ready(function () {
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) {
new bootstrap.Tooltip(el);
});
}
function calcHeight() {
return Math.max(300, $(window).height() - 210);
}
$('#unified-table').bootstrapTable({
columns: buildColumns(),
height: calcHeight(),
search: false,
pagination: true,
pageSize: 100,
clickToSelect: true,
ignoreClickToSelectOn: function (element) {
return $(element).closest('.zoom-btn, .unified-code-link').length > 0;
},
onCheck: function (row) { row.state = true; selectedIds.add(row.id); updateSelectionStatus(); },
onUncheck: function (row) { row.state = false; selectedIds.delete(row.id); updateSelectionStatus(); },
onCheckAll: function (rows) { rows.forEach(r => { r.state = true; selectedIds.add(r.id); }); updateSelectionStatus(); },
onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
onClickRow: function (row) {
if (!row.lon || !row.lat) return;
const source = row.is_location ? 'locations' : 'assets';
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
}
},
});
$(window).on('resize', function () {
$('#unified-table').bootstrapTable('resetView', { height: calcHeight() });
});
$('#unified-table').on('column-switch.bs.table', function () {
if (isApplyingSavedColumnVisibility) return;
schedulePreferencesSave();
});
// ─── Thematic checkboxes ────────────────────────────────────────────────
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.addEventListener('change', function () {
if (this.checked) activeThematics.add(this.value);
else activeThematics.delete(this.value);
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
});
});
document.getElementById('thematic-select-all').addEventListener('click', function () {
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.checked = true;
activeThematics.add(cb.value);
});
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
});
document.getElementById('thematic-deselect-all').addEventListener('click', function () {
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.checked = false;
});
activeThematics.clear();
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
});
document.getElementById('category-select-all').addEventListener('click', function () {
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
renderCategoryFilters();
renderModelFilters();
});
document.getElementById('category-deselect-all').addEventListener('click', function () {
activeCategories.clear();
renderCategoryFilters();
renderModelFilters();
});
document.getElementById('model-select-all').addEventListener('click', function () {
getVisibleModelOptions().forEach(model => activeModels.add(model.value));
renderModelFilters();
});
document.getElementById('model-deselect-all').addEventListener('click', function () {
activeModels.clear();
renderModelFilters();
});
document.getElementById('model-filter-toggle').addEventListener('click', function () {
modelFilterCollapsed = !modelFilterCollapsed;
renderModelFilters();
});
// ─── Type radio ────────────────────────────────────────────────────────
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
rb.addEventListener('change', function () {
if (this.checked) currentTypeFilter = this.value;
});
});
// ─── include-no-geom checkbox ──────────────────────────────────────────
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
if (includeNoGeomCheckbox) {
includeNoGeomCheckbox.addEventListener('change', function () {
refreshData();
schedulePreferencesSave();
});
}
// init from locked element type
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
// ─── Add actions ─────────────────────────────────────────────────────
const addConfig = getUnifiedAddModalConfig();
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : [];
const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : [];
const addButton = document.getElementById('btn-add-asset');
const assetThematicSelect = document.getElementById('unified-add-asset-thematic');
const locationThematicSelect = document.getElementById('unified-add-location-thematic');
const chooserEmpty = document.getElementById('unified-add-chooser-empty');
fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0);
if (addButton && !assetThematics.length && !locationThematics.length) {
addButton.disabled = true;
addButton.title = gettext('Vous n\'avez pas de droits de création');
}
addButton?.addEventListener('click', function () {
bootstrap.Modal.getOrCreateInstance(document.getElementById('unifiedAddChooserModal')).show();
});
assetThematicSelect?.addEventListener('change', function () {
unifiedAddAssetThematic = assetThematics.find(item => item.code === this.value) || null;
});
locationThematicSelect?.addEventListener('change', function () {
unifiedAddLocationThematic = locationThematics.find(item => item.code === this.value) || null;
});
document.getElementById('unified-add-asset-open')?.addEventListener('click', function () {
const thematicCode = assetThematicSelect ? assetThematicSelect.value : '';
if (!thematicCode) {
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer un asset.'), 'warning');
return;
}
setModalAlert('unified-add-chooser-alert', '', 'info');
const chooserEl = document.getElementById('unifiedAddChooserModal');
const chooser = bootstrap.Modal.getInstance(chooserEl);
if (chooser) {
const onHidden = () => {
openUnifiedAssetModal(thematicCode);
chooserEl.removeEventListener('hidden.bs.modal', onHidden);
};
chooserEl.addEventListener('hidden.bs.modal', onHidden);
chooser.hide();
} else {
openUnifiedAssetModal(thematicCode);
}
});
document.getElementById('unified-add-location-open')?.addEventListener('click', function () {
const thematicCode = locationThematicSelect ? locationThematicSelect.value : '';
if (!thematicCode) {
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une localisation.'), 'warning');
return;
}
setModalAlert('unified-add-chooser-alert', '', 'info');
const chooserEl = document.getElementById('unifiedAddChooserModal');
const chooser = bootstrap.Modal.getInstance(chooserEl);
if (chooser) {
const onHidden = () => {
openUnifiedLocationModal(thematicCode);
chooserEl.removeEventListener('hidden.bs.modal', onHidden);
};
chooserEl.addEventListener('hidden.bs.modal', onHidden);
chooser.hide();
} else {
openUnifiedLocationModal(thematicCode);
}
});
document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
loadUnifiedAssetModels(thematicCode, this.value);
});
document.getElementById('unifiedAddAssetForm')?.addEventListener('submit', async function (e) {
e.preventDefault();
const locationGroup = document.getElementById('unifiedAddAssetLocationGroup');
const locationSelect = document.getElementById('unifiedAddAssetLocation');
if (shouldUseTrafficlightsLocationCreateFlow(unifiedAddAssetThematic) && locationGroup && !locationGroup.classList.contains('d-none') && locationSelect && !locationSelect.value) {
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Sélectionnez une localisation avant de créer cet asset.'))}`, 'warning');
return;
}
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
const originalText = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${gettext('Création...')}`;
}
setModalAlert('unifiedAddAssetAlert', '', 'info');
const formData = new FormData(this);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
if (!data.location_id) {
delete data.location_id;
}
let createUrl = '/assets/api/create-asset/';
let payload = data;
if (shouldUseTrafficlightsLocationCreateFlow(unifiedAddAssetThematic)) {
const selectedModelOption = document.getElementById('unifiedAddAssetModel')?.selectedOptions?.[0];
const modelType = selectedModelOption?.dataset?.modelType || '';
const assetType = modelType.replace(/model$/i, '');
if (!assetType) {
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Type d\'asset introuvable pour ce modèle.'))}`, 'danger');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
return;
}
createUrl = `/assets/${data.thematic_code}/${unifiedAddAssetThematic.location_type}/${data.location_id}/create-asset/`;
payload = {
asset_type: assetType,
model_id: data.model_id || null,
category_id: data.category_id || null,
data: {
name_fr: data.name_fr || '',
name_nl: data.name_nl || '',
},
};
}
try {
const response = await fetch(createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(payload),
});
let result = null;
try {
result = await response.json();
} catch (e) {
result = null;
}
if (response.ok && result && result.success) {
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.message || gettext('Asset créé.'))}`, 'success');
setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById('unifiedAddAssetModal'))?.hide();
const fallbackUrl = (result.asset_id && payload.asset_type)
? `/assets/${data.thematic_code}/${payload.asset_type}/${result.asset_id}/`
: '';
const redirectUrl = result.redirect_url || fallbackUrl;
if (redirectUrl) window.location.href = redirectUrl;
else window.location.reload();
}, 800);
} else {
const errorMsg = (result && (result.error || result.message))
|| (response.status ? `${gettext('Erreur serveur')} (${response.status})` : gettext('Erreur lors de la création.'));
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(errorMsg)}`, 'danger');
}
} catch (error) {
console.error('Error creating asset:', error);
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(error.message || gettext('Erreur de connexion.'))}`, 'danger');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
}
});
document.getElementById('unifiedAddLocationForm')?.addEventListener('submit', async function (e) {
e.preventDefault();
const thematicCode = document.getElementById('unified-add-location-thematic-code')?.value || '';
const locationType = document.getElementById('unified-add-location-type')?.value || '';
if (!thematicCode || !locationType) return;
const submitBtn = document.getElementById('unifiedAddLocationSubmit');
const originalText = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${gettext('Création...')}`;
}
setModalAlert('unifiedAddLocationAlert', '', 'info');
const formData = new FormData(this);
try {
const response = await fetch(`/assets/locations/${thematicCode}/${locationType}/create/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const result = await response.json();
if (result.success) {
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.label || gettext('Localisation créée.'))}`, 'success');
setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById('unifiedAddLocationModal'))?.hide();
window.location.reload();
}, 800);
} else {
const errors = result.errors ? Object.values(result.errors).flat().join(' ') : result.error;
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(errors || gettext('Erreur lors de la création.'))}`, 'danger');
}
} catch (error) {
console.error('Error creating location:', error);
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Erreur de connexion.'))}`, 'danger');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
}
});
document.getElementById('unifiedAddAssetModal')?.addEventListener('hidden.bs.modal', resetUnifiedAssetForm);
document.getElementById('unifiedAddLocationModal')?.addEventListener('hidden.bs.modal', function () {
if (unifiedLocationPickerMap) {
unifiedLocationPickerMap.remove();
unifiedLocationPickerMap = null;
unifiedLocationPickerMarker = null;
unifiedLocationPickerControl = null;
}
resetUnifiedLocationForm();
});
// ─── Search ────────────────────────────────────────────────────────────
function doSearch() {
const raw = document.getElementById('assets-search-input').value.trim();
searchTerms = raw ? raw.toLowerCase().split(/\s+/) : [];
const clearBtn = document.getElementById('assets-search-clear');
if (clearBtn) clearBtn.style.visibility = raw ? 'visible' : 'hidden';
if (searchTerms.length > 0) {
shouldFitSearch = true;
searchIsGlobal = true;
} else {
searchIsGlobal = false;
}
refreshData();
}
document.getElementById('assets-search-btn').addEventListener('click', doSearch);
document.getElementById('assets-search-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') doSearch();
});
document.getElementById('assets-search-clear').addEventListener('click', function () {
document.getElementById('assets-search-input').value = '';
doSearch();
});
document.querySelectorAll('input[name="assets-search-mode"]').forEach(rb => {
rb.addEventListener('change', function () {
searchMode = this.value;
if (searchTerms.length) {
shouldFitSearch = true;
searchIsGlobal = true;
refreshData();
}
});
});
// ─── Filters ───────────────────────────────────────────────────────────
document.getElementById('apply-filters').addEventListener('click', function () {
refreshData();
updateFilterBadge();
schedulePreferencesSave();
const modal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
if (modal) modal.hide();
});
document.getElementById('reset-filters').addEventListener('click', function () {
document.querySelectorAll('.thematic-checkbox').forEach(cb => { cb.checked = false; });
activeThematics.clear();
activeCategories.clear();
activeModels.clear();
modelFilterCollapsed = true;
activeConditionalFilters = {};
const allRadio = document.getElementById('type-all');
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; });
document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; });
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
if (includeNoGeomCheckbox) includeNoGeomCheckbox.checked = true;
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
refreshData();
updateFilterBadge();
schedulePreferencesSave();
const modal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
if (modal) modal.hide();
});
// ─── Select all / deselect all ─────────────────────────────────────────
function selectAllTableRows() {
const allRows = $('#unified-table').bootstrapTable('getData') || [];
allRows.forEach(r => {
selectedIds.add(r.id);
r.state = true;
});
$('#unified-table').bootstrapTable('checkAll');
updateSelectionStatus();
}
function deselectAllTableRows() {
const allRows = $('#unified-table').bootstrapTable('getData') || [];
allRows.forEach(r => {
r.state = false;
});
selectedIds.clear();
$('#unified-table').bootstrapTable('uncheckAll');
updateSelectionStatus();
}
document.getElementById('unified-select-all').addEventListener('click', selectAllTableRows);
document.getElementById('unified-deselect-all').addEventListener('click', deselectAllTableRows);
bindUnifiedAssetsExport();
bindUnifiedBulkActions();
// ─── Layout buttons ────────────────────────────────────────────────────
document.getElementById('layout-btn-table').addEventListener('click', function () {
document.getElementById('col-table').className = 'col-12';
document.getElementById('col-map').classList.add('d-none');
setLayoutActive('table');
});
document.getElementById('layout-btn-both').addEventListener('click', function () {
document.getElementById('col-table').className = 'col-md-6';
const colMap = document.getElementById('col-map');
colMap.className = 'col-md-6';
colMap.style.cssText = 'position: relative; margin-top: 50px;';
setLayoutActive('both');
});
document.getElementById('layout-btn-map').addEventListener('click', function () {
document.getElementById('col-table').classList.add('d-none');
const colMap = document.getElementById('col-map');
colMap.className = 'col-12';
colMap.style.cssText = 'position: relative; margin-top: 50px;';
setLayoutActive('map');
});
function setLayoutActive(mode) {
['table', 'both', 'map'].forEach(m => {
const btn = document.getElementById(`layout-btn-${m}`);
btn.classList.toggle('text-primary', m === mode);
});
if (typeof map !== 'undefined' && map.resize) map.resize();
$('#unified-table').bootstrapTable('resetView', { height: calcHeight() });
}
// ─── Init ──────────────────────────────────────────────────────────────
applyInitialPreferences();
buildCategoryLookup();
renderCategoryFilters();
renderModelFilters();
renderConditionalFilters();
updateFilterBadge();
if (activeThematics.size > 0) {
refreshData();
} else {
showEmptyState();
}
// Pre-select items from intervention draft
if (SELECTED_LOCATION_IDS && SELECTED_LOCATION_IDS.length) {
SELECTED_LOCATION_IDS.forEach(id => selectedIds.add(id));
}
if (SELECTED_ASSETS && SELECTED_ASSETS.length) {
SELECTED_ASSETS.forEach(a => selectedIds.add(a.id || a));
}
// Zoom button: fly to location then refresh data (moveend from flyTo is
// programmatic and would be filtered out by the standard moveend handler)
$('#unified-table').on('click', '.zoom-btn', function (e) {
e.preventDefault();
e.stopPropagation();
const lon = parseFloat($(this).data('lon'));
const lat = parseFloat($(this).data('lat'));
if (!isNaN(lon) && !isNaN(lat)) {
showZoomFocusMarker(lon, lat);
searchIsGlobal = false;
map.flyTo({ center: [lon, lat], zoom: 19, essential: true });
map.once('moveend', function () {
refreshData();
});
}
});
});
// ─── Asset loading helpers ─────────────────────────────────────────────────────
function hasTriggerFilter() {
if (activeCategories.size > 0) return true;
if (activeModels.size > 0) return true;
const publiclightingFilters = activeConditionalFilters.publiclighting || {};
if (Array.isArray(publiclightingFilters.municipality) && publiclightingFilters.municipality.length) return true;
const trafficlightsFilters = activeConditionalFilters.trafficlights || {};
if (Array.isArray(trafficlightsFilters.status) && trafficlightsFilters.status.length) return true;
if (Array.isArray(trafficlightsFilters.voltage) && trafficlightsFilters.voltage.length) return true;
if (trafficlightsFilters.connected) return true;
if (trafficlightsFilters.controller_maintenance_provider) return true;
if (trafficlightsFilters.asset_begin_date) return true;
const structuresFilters = activeConditionalFilters.structures || {};
if (Array.isArray(structuresFilters.structure_type) && structuresFilters.structure_type.length) return true;
if (structuresFilters.is_main_location) return true;
const natureFilters = activeConditionalFilters.nature || {};
if (natureFilters.location_type) return true;
if (natureFilters.highway) return true;
const roadsFilters = activeConditionalFilters.roads || {};
if (Array.isArray(roadsFilters.municipality) && roadsFilters.municipality.length) return true;
if (Array.isArray(roadsFilters.quality_level) && roadsFilters.quality_level.length) return true;
const signFilters = activeConditionalFilters.sign || {};
if (Array.isArray(signFilters.municipality) && signFilters.municipality.length) return true;
if (Array.isArray(signFilters.panel_type) && signFilters.panel_type.length) return true;
const outdoorFilters = activeConditionalFilters.outdoorfacilities || activeConditionalFilters.outdoor || {};
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
if (outdoorFilters.pmr_accessible) return true;
return false;
}
function getMapBbox() {
if (typeof map === 'undefined' || !map.getBounds) return null;
const b = map.getBounds();
const sw = b.getSouthWest(), ne = b.getNorthEast();
return {
type: 'Polygon',
coordinates: [[
[sw.lng, sw.lat], [ne.lng, sw.lat],
[ne.lng, ne.lat], [sw.lng, ne.lat],
[sw.lng, sw.lat],
]],
};
}
function getSelectedStatusFilters() {
return Array.from(document.querySelectorAll('[name="filter-status"]:checked')).map(cb => cb.value);
}
function getSelectedValidationStatusFilters() {
return Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
}
function getActiveCategoryIdsForThematic(thematicCode) {
const categories = THEMATIC_CATEGORIES[thematicCode] || [];
const ids = [];
categories.forEach(category => {
const categoryValue = `${thematicCode}::${category.name || ''}`;
if (activeCategories.has(categoryValue)) {
ids.push(String(category.id));
}
});
return ids;
}
function getActiveModelIdsForThematic(thematicCode) {
const ids = [];
getVisibleModelOptions().forEach(model => {
if (model.thematicCode !== thematicCode) return;
if (!activeModels.has(model.value)) return;
if (model.modelId == null) return;
const parsed = Number(model.modelId);
if (!Number.isFinite(parsed)) return;
ids.push(parsed);
});
return Array.from(new Set(ids));
}
function buildLocationsGeojsonPostBody(thematicCode) {
const payload = {};
const thematicFilters = getConditionalFiltersForThematic(thematicCode);
if (thematicCode === 'trafficlights') {
if (Array.isArray(thematicFilters.status) && thematicFilters.status.length) {
payload.status = thematicFilters.status;
}
if (Array.isArray(thematicFilters.voltage) && thematicFilters.voltage.length) {
payload.voltage = thematicFilters.voltage;
}
if (thematicFilters.connected) payload.connected = thematicFilters.connected;
if (thematicFilters.asset_begin_date) payload.begin_date = thematicFilters.asset_begin_date;
if (thematicFilters.controller_maintenance_provider) {
payload.controller_maintenance_provider = thematicFilters.controller_maintenance_provider;
}
}
if (['roads', 'publiclighting', 'sign', 'artworks'].includes(thematicCode)) {
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
payload.municipality = thematicFilters.municipality;
}
}
if (['structures', 'its', 'controlcenters'].includes(thematicCode)) {
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
}
if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type;
}
}
if (thematicCode === 'nature') {
if (thematicFilters.location_type) payload.location_type = thematicFilters.location_type;
if (thematicFilters.highway) payload.highway = thematicFilters.highway;
}
return payload;
}
function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const payload = {
include_no_geom: includeNoGeom,
};
if (bbox) {
payload.filter_geom = { type: 'Feature', geometry: bbox };
}
const assetsSearchInput = document.getElementById('assets-search-input');
const rawSearch = assetsSearchInput ? assetsSearchInput.value.trim() : '';
if (rawSearch) {
payload.q = rawSearch;
payload.search_mode = searchMode;
}
if (!includeNonGeoFilters) return payload;
const modelIds = getActiveModelIdsForThematic(thematicCode);
if (modelIds.length) payload.model = modelIds;
else {
const categoryIds = getActiveCategoryIdsForThematic(thematicCode);
if (categoryIds.length) payload.category = categoryIds;
}
const statusFilters = getSelectedStatusFilters();
if (statusFilters.length) payload.asset_status = statusFilters;
const validationStatusFilters = getSelectedValidationStatusFilters();
if (validationStatusFilters.length) payload.asset_validation_status = validationStatusFilters;
const thematicFilters = getConditionalFiltersForThematic(thematicCode);
if (thematicCode === 'roads') {
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
payload.municipality = thematicFilters.municipality;
}
if (Array.isArray(thematicFilters.quality_level) && thematicFilters.quality_level.length) {
payload.quality_level = thematicFilters.quality_level;
}
}
if (thematicCode === 'trafficlights') {
if (Array.isArray(thematicFilters.status) && thematicFilters.status.length) {
payload.status = thematicFilters.status;
}
if (Array.isArray(thematicFilters.voltage) && thematicFilters.voltage.length) {
payload.voltage = thematicFilters.voltage;
}
if (thematicFilters.connected) {
payload.connected = thematicFilters.connected;
}
if (thematicFilters.controller_maintenance_provider) {
payload.controller_maintenance_provider = thematicFilters.controller_maintenance_provider;
}
if (thematicFilters.asset_begin_date) {
payload.asset_begin_date = thematicFilters.asset_begin_date;
}
}
if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type;
}
if (thematicFilters.is_main_location) {
payload.is_main_location = thematicFilters.is_main_location;
}
}
if (thematicCode === 'publiclighting') {
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
payload.municipality = thematicFilters.municipality;
}
}
if (thematicCode === 'nature') {
if (thematicFilters.location_type) {
payload.location_type = thematicFilters.location_type;
}
if (thematicFilters.highway) {
payload.highway = thematicFilters.highway;
}
}
if (thematicCode === 'sign') {
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
payload.municipality = thematicFilters.municipality;
}
if (Array.isArray(thematicFilters.panel_type) && thematicFilters.panel_type.length) {
payload.panel_type = thematicFilters.panel_type;
}
}
const searchInput = document.getElementById('search');
const searchQuery = searchInput ? (searchInput.value || '').trim() : '';
if (searchQuery) payload.q = searchQuery;
return payload;
}
function getCsrfToken() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function getSelectedAssetKeysForExport() {
const rows = $('#unified-table').bootstrapTable('getData') || [];
const keys = [];
const seen = new Set();
rows.forEach(row => {
if (!row || row.is_location) return;
if (selectedIds.size > 0 && !selectedIds.has(row.id)) return;
const modelName = String(row.asset_type || '').toLowerCase().trim();
const objectId = Number(row.id);
if (!modelName || !Number.isFinite(objectId)) return;
const key = `${modelName}:${objectId}`;
if (seen.has(key)) return;
seen.add(key);
keys.push(key);
});
return keys;
}
function bindUnifiedAssetsExport() {
const exportConfirmBtn = document.getElementById('unified-confirm-export-btn');
const exportError = document.getElementById('unified-export-error');
const exportModalEl = document.getElementById('unifiedExportModal');
if (!exportConfirmBtn || !exportModalEl) return;
exportModalEl.addEventListener('show.bs.modal', function () {
if (!exportError) return;
exportError.classList.add('d-none');
exportError.textContent = '';
const currentSelectedKeys = getSelectedAssetKeysForExport();
if (!currentSelectedKeys.length) {
selectAllTableRows();
}
});
exportConfirmBtn.addEventListener('click', function () {
let ids = getSelectedAssetKeysForExport();
if (!ids.length) {
selectAllTableRows();
ids = getSelectedAssetKeysForExport();
}
if (!ids.length) {
if (exportError) {
exportError.textContent = gettext('Veuillez sélectionner au moins un asset.');
exportError.classList.remove('d-none');
}
return;
}
if (exportError) {
exportError.classList.add('d-none');
exportError.textContent = '';
}
const format = document.querySelector('input[name="unified-export-format"]:checked')?.value || 'csv';
const form = document.createElement('form');
form.method = 'POST';
form.action = exportAssetsUrl;
form.style.display = 'none';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = getCsrfToken();
form.appendChild(csrfInput);
const formatInput = document.createElement('input');
formatInput.type = 'hidden';
formatInput.name = 'format';
formatInput.value = format;
form.appendChild(formatInput);
const idsInput = document.createElement('input');
idsInput.type = 'hidden';
idsInput.name = 'ids';
idsInput.value = JSON.stringify(ids);
form.appendChild(idsInput);
document.body.appendChild(form);
bootstrap.Modal.getOrCreateInstance(exportModalEl).hide();
form.submit();
setTimeout(() => {
if (form.parentNode) form.parentNode.removeChild(form);
}, 1000);
});
}
function buildBulkSelectedAssetsPayload() {
const rows = $('#unified-table').bootstrapTable('getData') || [];
const byId = new Map(rows.filter(r => !r.is_location).map(r => [String(r.id), r]));
const payload = [];
Array.from(selectedIds).forEach(rawId => {
const id = String(rawId);
const row = byId.get(id);
if (!row || !row.asset_type) return;
payload.push({ model: String(row.asset_type).toLowerCase().trim(), object_id: Number(id) });
});
return payload;
}
function bindUnifiedBulkActions() {
// 1. Bulk Status
const bulkStatusConfirmBtn = document.getElementById('bulk-status-confirm-btn');
const bulkStatusError = document.getElementById('bulk-status-error');
const bulkStatusModalEl = document.getElementById('bulkStatusModal');
if (bulkStatusConfirmBtn && bulkStatusModalEl) {
bulkStatusModalEl.addEventListener('show.bs.modal', function () {
if (bulkStatusError) {
bulkStatusError.classList.add('d-none');
bulkStatusError.textContent = '';
}
});
bulkStatusConfirmBtn.addEventListener('click', function () {
const assets = buildBulkSelectedAssetsPayload();
if (!assets.length) {
if (bulkStatusError) {
bulkStatusError.textContent = gettext('Veuillez sélectionner au moins un asset.');
bulkStatusError.classList.remove('d-none');
}
return;
}
const newValue = document.getElementById('bulk-status-select').value;
fetch(bulkEditAssetsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
assets: assets,
field_name: 'status',
new_value: newValue
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkStatusModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
if (bulkStatusError) {
bulkStatusError.textContent = data.error || gettext('Une erreur est survenue.');
bulkStatusError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkStatusError) {
bulkStatusError.textContent = gettext('Une erreur est survenue.');
bulkStatusError.classList.remove('d-none');
}
});
});
}
// 2. Bulk Validation Status
const bulkValConfirmBtn = document.getElementById('bulk-validation-status-confirm-btn');
const bulkValError = document.getElementById('bulk-validation-status-error');
const bulkValModalEl = document.getElementById('bulkValidationStatusModal');
if (bulkValConfirmBtn && bulkValModalEl) {
bulkValModalEl.addEventListener('show.bs.modal', function () {
if (bulkValError) {
bulkValError.classList.add('d-none');
bulkValError.textContent = '';
}
});
bulkValConfirmBtn.addEventListener('click', function () {
const assets = buildBulkSelectedAssetsPayload();
if (!assets.length) {
if (bulkValError) {
bulkValError.textContent = gettext('Veuillez sélectionner au moins un asset.');
bulkValError.classList.remove('d-none');
}
return;
}
const newValue = document.getElementById('bulk-validation-status-select').value;
fetch(bulkEditAssetsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
assets: assets,
field_name: 'validation_status',
new_value: newValue
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkValModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
if (bulkValError) {
bulkValError.textContent = data.error || gettext('Une erreur est survenue.');
bulkValError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkValError) {
bulkValError.textContent = gettext('Une erreur est survenue.');
bulkValError.classList.remove('d-none');
}
});
});
}
}
function showLoadWarning(message) {
const warning = document.getElementById('unified-load-warning');
if (!warning) return;
warning.textContent = message;
warning.classList.remove('d-none');
loadWarningVisible = true;
}
function hideLoadWarning() {
const warning = document.getElementById('unified-load-warning');
if (!warning) return;
warning.textContent = '';
warning.classList.add('d-none');
loadWarningVisible = false;
}
function handleLoadTooLarge() {
showLoadWarning(gettext("Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre pour charger le tableau et la carte."));
}
async function fetchGeojsonFeatures(url, options, signal) {
const response = await fetch(url, options);
const contentType = response.headers.get('content-type') || '';
const bodyText = await response.text().catch(() => '');
if (response.status === 413 || /Content Too Large|Trop d'objets à afficher|too large/i.test(bodyText)) {
const error = new Error('Too Large');
error.code = 'TOO_LARGE';
throw error;
}
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
error.contentType = contentType;
error.bodyText = bodyText;
throw error;
}
if (bodyText.trim() === '') return { type: 'FeatureCollection', features: [] };
if (contentType.includes('json') || bodyText.trim().startsWith('{') || bodyText.trim().startsWith('[')) {
return JSON.parse(bodyText);
}
return { type: 'FeatureCollection', features: [] };
}
function showZoomFocusMarker(lon, lat) {
if (typeof map === 'undefined') return;
if (!Number.isFinite(lon) || !Number.isFinite(lat) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
return;
}
if (zoomFocusMarker) {
zoomFocusMarker.setLngLat([lon, lat]);
return;
}
zoomFocusMarker = new maplibregl.Marker({ color: '#dc3545' })
.setLngLat([lon, lat])
.addTo(map);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeCssSelectorValue(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return String(value).replace(/(["\\#.:\[\]\(\)=,>+~*^$|!])/g, '\\$1');
}
function findUnifiedRowIndex(rowRef) {
const $table = $('#unified-table');
const rows = $table.bootstrapTable('getData') || [];
const isLocation = !!rowRef.is_location;
const matches = (row) => {
if (!!row.is_location !== isLocation) return false;
if (String(row.id) !== String(rowRef.id)) return false;
if (!isLocation) {
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
} else {
if (rowRef.thematic && String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
}
return true;
};
let idx = rows.findIndex(matches);
if (idx !== -1) return idx;
// Fallback in case some rows do not expose all discriminators.
idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id));
return idx;
}
function focusRowInUnifiedTable(rowRef) {
const bothBtn = document.getElementById('layout-btn-both');
if (bothBtn && document.getElementById('col-table').classList.contains('d-none')) {
bothBtn.click();
}
const $table = $('#unified-table');
if (!$table.length) return;
const rowIndex = findUnifiedRowIndex(rowRef);
if (rowIndex === -1) {
console.warn('Row not found in unified table', rowRef);
return;
}
const options = $table.bootstrapTable('getOptions') || {};
const pageSize = options.pageSize || 100;
const pageNumber = Math.floor(rowIndex / pageSize) + 1;
$table.bootstrapTable('selectPage', pageNumber);
setTimeout(() => {
$table.find('tbody td').removeClass('rowhighlight');
const $row = $table.find(`tbody tr[data-index="${rowIndex}"]`);
if ($row.length) {
$row.find('td').addClass('rowhighlight');
$row[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 250);
}
function collectClickedAssets(clickEvent) {
const layers = [
'assets',
'assets-fill',
'assets-polygon-outline',
'assets-line',
'assets-point',
'sign-panels-circles',
'sign-panels-symbols',
'sign-poles-orientation',
'sign-poles',
'sign-poles-vertical',
].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
const unique = new Map();
rendered.forEach(f => {
const p = f.properties || {};
const id = f.id ?? p.id;
if (id == null) return;
const key = `${p.thematic || ''}|${p.asset_type || ''}|${id}`;
if (unique.has(key)) return;
unique.set(key, {
id: id,
code: p.code || '',
name: p.name || p.name_fr || p.name_nl || '',
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '',
asset_type: p.asset_type || '',
element_type: p.element_type || '',
detail_url: p.detail_url || null,
feature_id: id,
});
});
return Array.from(unique.values());
}
function collectClickedLocations(clickEvent) {
const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
const unique = new Map();
rendered.forEach(f => {
const p = f.properties || {};
const id = f.id ?? p.id;
if (id == null) return;
const key = `${p.thematic || ''}|${id}`;
if (unique.has(key)) return;
unique.set(key, {
id: id,
code: p.code || '',
name: p.name || p.name_fr || p.name_nl || '',
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '',
element_type: p.element_type || '',
detail_url: p.detail_url || null,
feature_id: id,
});
});
return Array.from(unique.values());
}
function bindAssetPopupActions(popupNode) {
if (!popupNode) return;
popupNode.querySelectorAll('.asset-popup-focus-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
const rowRef = {
id: this.dataset.assetId,
thematic: this.dataset.thematic,
asset_type: this.dataset.assetType,
is_location: false,
};
focusRowInUnifiedTable(rowRef);
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, 'assets', rowRef.id, assetshighlightState);
}
});
});
popupNode.querySelectorAll('.location-popup-focus-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
const rowRef = {
id: this.dataset.locationId,
thematic: this.dataset.thematic,
is_location: true,
};
focusRowInUnifiedTable(rowRef);
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, 'locations', rowRef.id, locationshighlightState);
}
});
});
}
function showAssetClickPopup(clickEvent) {
const assets = collectClickedAssets(clickEvent);
const locations = collectClickedLocations(clickEvent);
if (!assets.length && !locations.length) return;
const maxItems = 8;
const visibleAssets = assets.slice(0, maxItems);
const hiddenCount = Math.max(0, assets.length - visibleAssets.length);
const itemsHtml = visibleAssets.map(a => {
const code = escapeHtml(a.code || '-');
const type = escapeHtml(a.element_type || a.asset_type || '');
const thematic = escapeHtml(a.thematic_name || a.thematic || '');
const name = escapeHtml(a.name || '');
const detailAnchor = a.detail_url
? `<a href="${escapeHtml(a.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
return `
<div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
class="btn btn-sm btn-light asset-popup-focus-btn"
data-asset-id="${escapeHtml(a.id)}"
data-thematic="${escapeHtml(a.thematic)}"
data-asset-type="${escapeHtml(a.asset_type)}"
title="${escapeHtml(gettext('Voir dans le tableau'))}"
style="transform:translateX(-5px);">
<i class="bi bi-table"></i>
</button>
</div>
`;
}).join('');
const visibleLocations = locations.slice(0, maxItems);
const hiddenLocationsCount = Math.max(0, locations.length - visibleLocations.length);
const locationItemsHtml = visibleLocations.map(l => {
const code = escapeHtml(l.code || '-');
const type = escapeHtml(l.element_type || '');
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
const name = escapeHtml(l.name || '');
const detailAnchor = l.detail_url
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
return `
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
class="btn btn-sm btn-light location-popup-focus-btn"
data-location-id="${escapeHtml(l.id)}"
data-thematic="${escapeHtml(l.thematic)}"
title="${escapeHtml(gettext('Voir dans le tableau'))}"
style="transform:translateX(-5px);">
<i class="bi bi-table"></i>
</button>
</div>
`;
}).join('');
const moreHtml = hiddenCount > 0
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenCount} ${escapeHtml(gettext('autre(s) asset(s)'))}</div>`
: '';
const moreLocationsHtml = hiddenLocationsCount > 0
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenLocationsCount} ${escapeHtml(gettext('autre(s) localisation(s)'))}</div>`
: '';
const assetsSectionHtml = assets.length > 0
? `
<div style="font-weight:700; margin-bottom:0.35rem;">${escapeHtml(gettext('Assets trouvés'))}: ${assets.length}</div>
<div>${itemsHtml}</div>
${moreHtml}
`
: '';
const locationsSectionHtml = locations.length > 0
? `
<div style="font-weight:700; margin:${assets.length ? '0.6rem' : '0'} 0 0.35rem 0;">${escapeHtml(gettext('Localisations trouvées'))}: ${locations.length}</div>
<div>${locationItemsHtml}</div>
${moreLocationsHtml}
`
: '';
const popupHtml = `
<div style="min-width:260px; max-width:320px;">
${assetsSectionHtml}
${locationsSectionHtml}
</div>
`;
if (!assetClickPopup) {
// Keep control of close behavior so consecutive clicks on features always reopen/update the popup.
assetClickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: false });
}
assetClickPopup
.setLngLat(clickEvent.lngLat)
.setHTML(popupHtml)
.addTo(map);
const popupNode = assetClickPopup.getElement();
bindAssetPopupActions(popupNode);
}
function updateAssetsHint() {
const hint = document.getElementById('assets-hint');
if (!hint) return;
const noThematic = activeThematics.size === 0;
const hasConditional = Array.from(activeThematics).some(
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code)
);
const filterActive = hasTriggerFilter();
const bboxActive = typeof map !== 'undefined' && map.getZoom && map.getZoom() >= MIN_ASSETS_ZOOM;
const showHint = noThematic || (hasConditional && !filterActive && !bboxActive);
hint.style.visibility = showHint ? 'visible' : 'hidden';
}
function getViewportFeatures(features) {
if (typeof map === 'undefined' || !map.getBounds) return features;
if (searchTerms && searchTerms.length > 0 && searchIsGlobal) return features;
const bounds = map.getBounds();
return features.filter(f => {
const geom = f.geometry;
if (!geom || !geom.coordinates) {
// Keep non-geolocated assets in the table; they cannot be spatially clipped.
return !!(f.properties && !f.properties.is_location);
}
let lng, lat;
try {
if (geom.type === 'Point') {
[lng, lat] = geom.coordinates;
} else if (geom.type === 'LineString' || geom.type === 'MultiPoint') {
[lng, lat] = geom.coordinates[0];
} else if (geom.type === 'Polygon' || geom.type === 'MultiLineString') {
[lng, lat] = geom.coordinates[0][0];
} else if (geom.type === 'MultiPolygon') {
[lng, lat] = geom.coordinates[0][0][0];
} else {
return true;
}
if (!Number.isFinite(lng) || !Number.isFinite(lat) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return false;
}
return bounds.contains([lng, lat]);
} catch (e) {
console.warn('getViewportFeatures: Invalid coordinate or geometry structure', f, e);
return false;
}
});
}
function fitMapToFeatures(features) {
if (typeof map === 'undefined' || !map.fitBounds || !features || features.length === 0) return;
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
let hasCoords = false;
features.forEach(f => {
const geom = f.geometry;
if (!geom || !geom.coordinates) return;
function processCoords(coords) {
if (!Array.isArray(coords)) return;
if (typeof coords[0] === 'number') {
const lng = coords[0];
const lat = coords[1];
if (Number.isFinite(lng) && Number.isFinite(lat) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
if (lng < minLng) minLng = lng;
if (lat < minLat) minLat = lat;
if (lng > maxLng) maxLng = lng;
if (lat > maxLat) maxLat = lat;
hasCoords = true;
}
} else {
coords.forEach(processCoords);
}
}
processCoords(geom.coordinates);
});
if (hasCoords) {
map.fitBounds([
[minLng, minLat],
[maxLng, maxLat]
], {
padding: 80,
maxZoom: 17,
duration: 1000
});
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
function refreshData() {
// Cancel any in-flight requests from the previous call
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
currentFetchAbortController = new AbortController();
const signal = currentFetchAbortController.signal;
hideLoadWarning();
const thematics = Array.from(activeThematics);
if (thematics.length === 0) {
allFeatures = [];
applyFilters();
return;
}
const filterActive = hasTriggerFilter();
const mapZoom = (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : 0;
const bboxActive = mapZoom >= MIN_ASSETS_ZOOM;
const fetches = [];
thematics.forEach(code => {
const cfg = THEMATIC_CONFIG[code];
if (!cfg) return;
if (!geojsonCache[code]) geojsonCache[code] = {};
// ── Locations: fetch once per thematic conditional-filter signature
const locationPayload = buildLocationsGeojsonPostBody(code);
const locationSignature = JSON.stringify(locationPayload);
if (cfg.locations_geojson_url && (!('locations' in geojsonCache[code]) || locationsFetchSignatures[code] !== locationSignature)) {
fetches.push(
fetchGeojsonFeatures(cfg.locations_geojson_url + '?unified=true', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(locationPayload),
signal,
}, signal)
.then(data => {
geojsonCache[code].locations = data.features || [];
locationsFetchSignatures[code] = locationSignature;
})
.catch(err => {
if (err.name === 'AbortError') throw err;
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
geojsonCache[code].locations = [];
locationsFetchSignatures[code] = locationSignature;
})
);
}
// ── Assets: conditional loading
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
const prevMode = assetsLoadModes[code] || null;
if (alwaysLoad) {
// artworks / controlcenters: fetch once, cache forever
if (!('assets' in geojsonCache[code])) {
const url = cfg.assets_geojson_url + '?unified=true';
fetches.push(
fetchGeojsonFeatures(url, { signal }, signal)
.then(data => { geojsonCache[code].assets = data.features || []; })
.catch(err => {
if (err.name === 'AbortError') throw err;
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
geojsonCache[code].assets = [];
})
);
assetsLoadModes[code] = 'always';
}
} else if (bboxActive || filterActive || (searchTerms && searchTerms.length > 0)) {
const useBbox = !searchIsGlobal;
const bbox = useBbox ? getMapBbox() : null;
const targetMode = useBbox ? 'bbox' : 'global';
const assetPayload = buildAssetsGeojsonPostBody(code, bbox, filterActive);
const assetSignature = JSON.stringify(assetPayload);
if (assetsLoadModes[code] !== targetMode || assetsFetchSignatures[code] !== assetSignature) {
if (!useBbox || bbox) {
fetches.push(
fetchGeojsonFeatures(cfg.assets_geojson_url + '?unified=true', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(assetPayload),
signal,
})
.then(data => {
geojsonCache[code].assets = data.features || [];
assetsFetchSignatures[code] = assetSignature;
})
.catch(err => {
if (err.name === 'AbortError') throw err;
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
geojsonCache[code].assets = [];
assetsFetchSignatures[code] = assetSignature;
})
);
assetsLoadModes[code] = targetMode;
}
}
} else {
// No condition met (search cleared, zoom out, etc): clear assets
geojsonCache[code].assets = [];
assetsLoadModes[code] = null;
assetsFetchSignatures[code] = null;
}
});
function afterFetch() {
allFeatures = [];
thematics.forEach(code => {
const cfg = THEMATIC_CONFIG[code];
const cache = geojsonCache[code] || {};
const thematicName = cfg ? cfg.name : code;
(cache.locations || []).forEach(f => {
f._thematic_name = thematicName;
allFeatures.push(f);
});
(cache.assets || []).forEach(f => {
f._thematic_name = thematicName;
allFeatures.push(f);
});
});
renderModelFilters();
applyFilters();
if (loadWarningVisible && allFeatures.length === 0) {
showEmptyState();
}
}
if (fetches.length > 0) {
showSpinner();
Promise.all(fetches)
.then(afterFetch)
.catch(err => { if (err.name !== 'AbortError') afterFetch(); });
} else {
afterFetch();
}
}
// ─── Filtering ────────────────────────────────────────────────────────────────
function applyFilters() {
let features = allFeatures.slice();
// Type filter
if (currentTypeFilter === 'locations') {
features = features.filter(f => f.properties && f.properties.is_location);
} else if (currentTypeFilter === 'assets') {
features = features.filter(f => f.properties && !f.properties.is_location);
}
// Include no geom filter
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
if (!includeNoGeom) {
features = features.filter(f => {
// Locations are never filtered by geometry checkbox (only assets)
if (f.properties && f.properties.is_location) return true;
return f.geometry && (f.geometry.coordinates || f.geometry.type);
});
}
// Status filter
const selectedStatuses = Array.from(document.querySelectorAll('[name="filter-status"]:checked')).map(cb => cb.value);
if (selectedStatuses.length) {
features = features.filter(f => {
return selectedStatuses.includes(f.properties && f.properties.status);
});
}
// Validation status filter
const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
if (selectedValidationStatuses.length) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
});
}
// Category filter
if (activeCategories.size) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
return activeCategories.has(getFeatureCategoryValue(f));
});
}
// Model filter
if (activeModels.size) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
return activeModels.has(getFeatureModelValue(f));
});
}
// Text search
if (searchTerms.length) {
features = features.filter(f => {
const p = f.properties || {};
const haystack = [p.code, p.name, p.name_fr, p.name_nl, p.element_type, p.thematic]
.filter(Boolean).join(' ').toLowerCase();
if (searchMode === 'or') return searchTerms.some(t => haystack.includes(t));
return searchTerms.every(t => haystack.includes(t));
});
}
filteredFeatures = features;
loadTableData(getViewportFeatures(features));
rebuildMapSources();
updateAssetsHint();
if (shouldFitSearch) {
shouldFitSearch = false;
fitMapToFeatures(features);
}
}
// ─── Table data ───────────────────────────────────────────────────────────────
function loadTableData(features) {
hideSpinner();
if (features.length === 0 && allFeatures.length === 0) {
$('#unified-spinner').hide();
$('#unified-table').bootstrapTable('load', []);
showEmptyState();
return;
}
hideEmptyState();
const rows = features.map(f => {
const p = f.properties || {};
const name = p.name || p.name_fr || p.name_nl || '';
const length = Number(p.length ?? p.length_m ?? 0);
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
return {
id: f.id,
state: selectedIds.has(f.id),
code: p.code || '',
name: name,
asset_type: p.asset_type || '',
element_type: p.element_type || '',
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '',
category_id: p.category_id != null ? String(p.category_id) : '',
category: p.category || '',
model: p.model || '',
status: p.status || '',
detail_url: p.detail_url || null,
lon: p.lon,
lat: p.lat,
length: Number.isFinite(length) ? length : 0,
area: Number.isFinite(area) ? area : 0,
is_location: p.is_location,
};
});
$('#unified-spinner').hide();
$('#unified-table').bootstrapTable('load', rows);
// Restore checked state
if (selectedIds.size) {
rows.forEach((row, idx) => {
if (selectedIds.has(row.id)) {
$('#unified-table').bootstrapTable('check', idx);
}
});
}
}
// ─── Map integration ──────────────────────────────────────────────────────────
var locationshighlightState = { previousId: null };
var assetshighlightState = { previousId: null };
function isPanelTextSpriteId(missingId) {
if (!missingId) return false;
return missingId.startsWith('panel-text-f34a-') ||
missingId.startsWith('panel-text-type') ||
missingId.startsWith('panel-text-fxx-') ||
missingId.startsWith('panel-text-f33c-') ||
missingId.startsWith('panel-text-xc-') ||
missingId.startsWith('panel-text-typexivf-') ||
missingId.startsWith('panel-text-icr-') ||
missingId.startsWith('panel-text-zone-');
}
function applyUnifiedSignPanelOffsets(featureCollection) {
if (!featureCollection || !Array.isArray(featureCollection.features)) return;
if (!window.SignPanelsShared || typeof window.SignPanelsShared.calculatePanelOffsets !== 'function') return;
window.SignPanelsShared.calculatePanelOffsets(featureCollection);
}
function scheduleUnifiedSignPanelOffsetRefresh() {
if (unifiedPanelOffsetRefreshTimer) {
clearTimeout(unifiedPanelOffsetRefreshTimer);
}
unifiedPanelOffsetRefreshTimer = setTimeout(function () {
unifiedPanelOffsetRefreshTimer = null;
if (!window.SignPanelsShared || typeof window.SignPanelsShared.refreshPanelOffsetsForSource !== 'function') {
return;
}
window.SignPanelsShared.refreshPanelOffsetsForSource(map, 'assets');
}, 40);
}
function enqueueUnifiedSignPanelTextSprites(features) {
if (!Array.isArray(features)) return;
if (typeof enqueuePanelTextSprites !== 'function') return;
try {
enqueuePanelTextSprites(features);
} catch (err) {
console.warn('Error enqueueing panel text sprites:', err);
}
}
function hasValidCoordinates(f) {
if (!f || !f.geometry) return false;
const geom = f.geometry;
if (!geom.coordinates) return false;
try {
if (geom.type === 'Point') {
return Number.isFinite(geom.coordinates[0]) && Number.isFinite(geom.coordinates[1]);
}
if (geom.type === 'LineString' || geom.type === 'MultiPoint') {
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && Number.isFinite(geom.coordinates[0][0]);
}
if (geom.type === 'Polygon' || geom.type === 'MultiLineString') {
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && geom.coordinates[0].length > 0 && Number.isFinite(geom.coordinates[0][0][0]);
}
if (geom.type === 'MultiPolygon') {
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && geom.coordinates[0].length > 0 && geom.coordinates[0][0].length > 0 && Number.isFinite(geom.coordinates[0][0][0][0]);
}
} catch (e) {
return false;
}
return true;
}
function rebuildMapSources() {
if (typeof map === 'undefined') return;
const locSrc = map.getSource('locations');
const asstSrc = map.getSource('assets');
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
mergedLocationsGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location),
};
mergedAssetsGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location),
};
applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
enqueueUnifiedSignPanelTextSprites(mergedAssetsGeojson.features);
locSrc.setData(mergedLocationsGeojson);
asstSrc.setData(mergedAssetsGeojson);
}
// ─── Map layers & initialization ──────────────────────────────────────────────
// Layer definitions and icon loading are in unified_map_layers.js (loaded first).
maploader.loadMap('map');
maploader.addEventListener('mapLoaded', () => {
// Create empty sources so setData can be called immediately after data loads
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
UNIFIED_LAYERS.forEach(layer => {
if (!map.getLayer(layer.id)) map.addLayer(layer);
});
// Load per-thematic Bootstrap Icons glyphs as SDF sprites
loadUnifiedMapIcons(map);
panelTextSpriteMap = map;
if (window.SignPanelsShared && typeof window.SignPanelsShared.loadRoadSignImages === 'function') {
window.SignPanelsShared.loadRoadSignImages(map);
}
map.on('styleimagemissing', function (e) {
const missingId = e && e.id;
if (!missingId) return;
if (isPanelTextSpriteId(missingId)) {
const hasDefinition = (typeof ensurePanelTextDefinitionFromMap === 'function')
? ensurePanelTextDefinitionFromMap(map, missingId)
: false;
if (!hasDefinition) return;
if (typeof ensurePanelTextSprite === 'function') {
ensurePanelTextSprite(map, missingId);
}
return;
}
if (window.SignPanelsShared && typeof window.SignPanelsShared.handleRoadSignStyleImageMissing === 'function') {
window.SignPanelsShared.handleRoadSignStyleImageMissing({
map,
event: e,
onImageUpdated: scheduleUnifiedSignPanelOffsetRefresh,
});
}
});
window.addEventListener('panelTextSpriteRendered', scheduleUnifiedSignPanelOffsetRefresh);
// Push any data already loaded before the map was ready
rebuildMapSources();
// ── Map controls ──────────────────────────────────────────────────────────
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
beforeLayer: 'locations',
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
},
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
tileSize: 256,
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
},
},
],
}],
});
// Reset view — fly back to initial Brussels Region view
map.addControl({
onAdd(m) {
this._map = m;
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const btn = document.createElement('button');
btn.type = 'button';
btn.title = gettext('Vue initiale (Bruxelles)');
btn.style.cssText = 'display:flex;align-items:center;justify-content:center;';
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 16 16"><path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207L8 2z"/></svg>';
btn.addEventListener('click', () => {
searchIsGlobal = false;
m.flyTo({ center: [4.3572, 50.8477], zoom: 11, duration: 1000 });
m.once('moveend', function () { refreshData(); });
});
this._container.appendChild(btn);
return this._container;
},
onRemove() {
this._container.parentNode?.removeChild(this._container);
this._map = undefined;
}
}, 'top-right');
map.addControl(layerSwitcher, 'top-right');
}
// Navigation (zoom +/-)
map.addControl(new maplibregl.NavigationControl(), 'top-right');
// Address search (custom autocomplete)
if (typeof AddressSearchControl !== 'undefined' && typeof AUTOCOMPLETE_STREET_URL !== 'undefined') {
map.addControl(new AddressSearchControl({ autocompleteUrl: AUTOCOMPLETE_STREET_URL }), 'top-left');
}
// Restore saved map position (set by applyInitialPreferences)
if (window._restoredMapCenter && window._restoredMapZoom) {
map.jumpTo({ center: window._restoredMapCenter, zoom: window._restoredMapZoom });
delete window._restoredMapCenter;
delete window._restoredMapZoom;
// Re-run with the correct zoom/bbox now that the map is at its saved position
refreshData();
}
let isBoxZooming = false;
map.on('boxzoomend', function () {
isBoxZooming = true;
});
let moveEndTimer = null;
map.on('moveend', function (e) {
// Ignore moveend not triggered by a real user gesture (e.g. from map.resize()
// called by MapLibre's internal ResizeObserver after a layout change).
// User-initiated events (pan, zoom, keyboard) always carry a native browser
// Event as originalEvent; programmatic / resize-triggered ones do not.
const isUserGesture = e && e.originalEvent && typeof e.originalEvent.type === 'string';
const isBoxZoomMove = isBoxZooming;
if (!isUserGesture && !isBoxZoomMove) return;
searchIsGlobal = false;
isBoxZooming = false;
// Compute immediately (moveend fires after the gesture, zoom is already final)
const zoom = map.getZoom();
const anyZoomRelevant = Array.from(activeThematics).some(
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code) &&
(zoom >= MIN_ASSETS_ZOOM || assetsLoadModes[code] === 'bbox')
);
// Show spinner right away for instant visual feedback — before the debounce delay
if (anyZoomRelevant) showSpinner();
clearTimeout(moveEndTimer);
moveEndTimer = setTimeout(() => {
if (anyZoomRelevant) refreshData();
else applyFilters(); // update table to current viewport without re-fetching
schedulePreferencesSave();
}, 200);
});
// Click interaction on map elements: show popup when assets are found at click position.
['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].forEach(layerId => {
if (!map.getLayer(layerId)) return;
map.on('click', layerId, showAssetClickPopup);
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical'].includes(layerId)) {
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
}
});
// Close popup only when clicking outside interactive layers.
map.on('click', function (e) {
if (!assetClickPopup || !assetClickPopup.isOpen()) return;
const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline']
.filter(layerId => map.getLayer(layerId));
if (!interactiveLayers.length) {
assetClickPopup.remove();
return;
}
const hits = map.queryRenderedFeatures(e.point, { layers: interactiveLayers });
if (hits.length === 0) assetClickPopup.remove();
});
});
// ─── UI helpers ───────────────────────────────────────────────────────────────
function showSpinner() {
const spinner = document.getElementById('unified-spinner');
// Position the overlay to start at the table body (below toolbar + thead)
// so headers and the columns button remain visible.
const tableBody = spinner.parentElement.querySelector('.fixed-table-body');
spinner.style.top = tableBody ? ((tableBody.offsetTop + 50) + 'px') : '0';
spinner.style.display = 'flex';
}
function hideSpinner() {
const spinner = document.getElementById('unified-spinner');
spinner.style.display = 'none';
spinner.style.top = '0'; // reset for next show
}
function showEmptyState() {
hideSpinner();
document.getElementById('unified-empty-state').style.display = '';
updateAssetsHint();
}
function hideEmptyState() {
document.getElementById('unified-empty-state').style.display = 'none';
}
function updateFilterBadge() {
const badge = document.getElementById('filter-badge');
const countEl = document.getElementById('filter-count');
if (!badge) return;
const hasFilterValue = (value) => {
if (Array.isArray(value)) return value.length > 0;
return value !== null && value !== undefined && value !== '' && value !== false;
};
let n = 0;
if (activeThematics.size > 0) n += 1;
if (activeCategories.size > 0) n += 1;
if (activeModels.size > 0) n += 1;
const thematicBlocksCount = Object.values(activeConditionalFilters).reduce((acc, filterMap) => {
if (!filterMap || typeof filterMap !== 'object') return acc;
return acc + Object.values(filterMap).reduce((subtotal, value) => {
return subtotal + (hasFilterValue(value) ? 1 : 0);
}, 0);
}, 0);
n += thematicBlocksCount;
if (currentTypeFilter !== 'all') n += 1;
if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1;
if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1;
if (countEl) countEl.textContent = n;
badge.classList.toggle('d-none', n === 0);
}
function updateSelectionStatus() {
const el = document.getElementById('unified-selection-status');
if (el) el.textContent = `${selectedIds.size} ${gettext('sélectionné(s)')}`;
// Update hidden form for preventive
const hiddenInput = document.getElementById('preventive-selected-ids');
if (hiddenInput) hiddenInput.value = Array.from(selectedIds).join(',');
const hiddenLocationsInput = document.getElementById('preventive-selected-locations-ids');
if (hiddenLocationsInput) hiddenLocationsInput.value = Array.from(selectedIds).join(',');
}
function buildPreventiveSelectedAssetsPayload() {
const rows = $('#unified-table').bootstrapTable('getData') || [];
const byId = new Map(rows.filter(r => !r.is_location).map(r => [String(r.id), r]));
const payload = [];
Array.from(selectedIds).forEach(rawId => {
const id = String(rawId);
const row = byId.get(id);
if (!row || !row.asset_type) return;
payload.push({ model: String(row.asset_type).toLowerCase().trim(), object_id: Number(id) });
});
return payload;
}
function bindPreventiveSelectionSubmit() {
const form = document.getElementById('preventive-select-form');
if (!form) return;
form.addEventListener('submit', function () {
const selectedCsv = Array.from(selectedIds).join(',');
const selectedIdsInput = document.getElementById('preventive-selected-ids');
const selectedLocationsInput = document.getElementById('preventive-selected-locations-ids');
const selectedAssetsInput = document.getElementById('preventive-selected-assets');
if (selectedIdsInput) selectedIdsInput.value = selectedCsv;
if (selectedLocationsInput) selectedLocationsInput.value = selectedCsv;
if (selectedAssetsInput) {
const assetsPayload = (PREVENTIVE_ELEMENT_TYPE === 'assets')
? buildPreventiveSelectedAssetsPayload()
: [];
selectedAssetsInput.value = JSON.stringify(assetsPayload);
}
});
}
function buildCategoryLookup() {
categoryLabelByThematicAndId = {};
categoryValueByThematicAndLabel = {};
Object.entries(THEMATIC_CATEGORIES || {}).forEach(([thematicCode, categories]) => {
categoryLabelByThematicAndId[thematicCode] = {};
categoryValueByThematicAndLabel[thematicCode] = {};
(categories || []).forEach(category => {
const categoryId = String(category.id);
const categoryLabel = category.name || '';
const categoryValue = `${thematicCode}::${categoryLabel}`;
categoryLabelByThematicAndId[thematicCode][categoryId] = categoryLabel;
categoryValueByThematicAndLabel[thematicCode][categoryLabel] = categoryValue;
});
});
}
function getFeatureCategoryValue(feature) {
const p = feature.properties || {};
const thematicCode = p.thematic || feature._thematic_code || '';
const categoryLabel = p.category || '';
const categoryId = p.category_id != null ? String(p.category_id) : '';
if (thematicCode && categoryLabel && categoryValueByThematicAndLabel[thematicCode] && categoryValueByThematicAndLabel[thematicCode][categoryLabel]) {
return categoryValueByThematicAndLabel[thematicCode][categoryLabel];
}
if (thematicCode && categoryId && categoryLabelByThematicAndId[thematicCode] && categoryLabelByThematicAndId[thematicCode][categoryId]) {
return `${thematicCode}::${categoryLabelByThematicAndId[thematicCode][categoryId]}`;
}
if (thematicCode && categoryLabel) {
return `${thematicCode}::${categoryLabel}`;
}
return '';
}
function getFeatureModelValue(feature) {
const p = feature.properties || {};
const thematicCode = p.thematic || feature._thematic_code || '';
const modelLabel = p.model || '';
if (!modelLabel) return '';
const categoryValue = getFeatureCategoryValue(feature);
if (categoryValue) {
const sepIndex = categoryValue.indexOf('::');
const categoryLabel = sepIndex >= 0 ? categoryValue.slice(sepIndex + 2) : '';
const categoryOption = getVisibleCategoryOptions().find(option => (
option.thematicCode === thematicCode &&
option.label === categoryLabel
));
if (categoryOption) {
const cacheKey = `${categoryOption.thematicCode}::${categoryOption.categoryId}`;
const models = modelsByCategoryKey[cacheKey] || [];
const matchedModel = models.find(model => model.label === modelLabel);
if (matchedModel && matchedModel.modelId != null) {
return `${categoryValue}::${String(matchedModel.modelId)}`;
}
}
// Fallback kept for robustness when model catalogs are not loaded yet.
return `${categoryValue}::${modelLabel}`;
}
if (thematicCode) {
return `${thematicCode}::::${modelLabel}`;
}
return `::::${modelLabel}`;
}
function getConditionalFilterValue(thematicCode, filterKey, filterType) {
const thematicFilters = activeConditionalFilters[thematicCode] || {};
const raw = thematicFilters[filterKey];
if (filterType === 'multi') {
return Array.isArray(raw) ? raw : [];
}
return typeof raw === 'string' ? raw : '';
}
function setConditionalFilterValue(thematicCode, filterKey, filterType, value) {
if (!activeConditionalFilters[thematicCode]) activeConditionalFilters[thematicCode] = {};
if (filterType === 'multi') {
activeConditionalFilters[thematicCode][filterKey] = value.filter(Boolean);
if (!activeConditionalFilters[thematicCode][filterKey].length) {
delete activeConditionalFilters[thematicCode][filterKey];
}
} else if (value) {
activeConditionalFilters[thematicCode][filterKey] = value;
} else {
delete activeConditionalFilters[thematicCode][filterKey];
}
if (Object.keys(activeConditionalFilters[thematicCode]).length === 0) {
delete activeConditionalFilters[thematicCode];
}
}
function pruneConditionalFiltersForActiveThematics() {
Object.keys(activeConditionalFilters).forEach(thematicCode => {
if (!activeThematics.has(thematicCode)) {
delete activeConditionalFilters[thematicCode];
}
});
}
function getConditionalFiltersForThematic(thematicCode) {
return activeConditionalFilters[thematicCode] || {};
}
function getVisibleCategoryOptions() {
const visible = [];
Array.from(activeThematics).forEach(thematicCode => {
const categories = THEMATIC_CATEGORIES[thematicCode] || [];
categories.forEach(category => {
visible.push({
thematicCode,
thematicName: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].name) || thematicCode,
categoryId: String(category.id),
label: category.name || '',
value: `${thematicCode}::${category.name || ''}`,
icon: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].icon) || '',
});
});
});
return visible;
}
function getSelectedCategoryOptions() {
return getVisibleCategoryOptions().filter(category => activeCategories.has(category.value));
}
function buildAssetModelsUrl(thematicCode, categoryId) {
if (!ASSET_MODELS_BY_CATEGORY_URL_TEMPLATE) return '';
return ASSET_MODELS_BY_CATEGORY_URL_TEMPLATE
.replace('THEMATIC_CODE', encodeURIComponent(thematicCode))
.replace('/0/models/', `/${encodeURIComponent(String(categoryId))}/models/`);
}
function ensureModelsLoadedForSelectedCategories() {
const selectedCategories = getSelectedCategoryOptions();
selectedCategories.forEach(category => {
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
if (modelsByCategoryKey[cacheKey] || pendingModelFetches[cacheKey]) return;
const url = buildAssetModelsUrl(category.thematicCode, category.categoryId);
if (!url) {
modelsByCategoryKey[cacheKey] = [];
return;
}
pendingModelFetches[cacheKey] = fetch(url)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
const byLabel = new Map();
(data.models || []).forEach(model => {
const label = String(model.name || model.code || '').trim();
if (!label) return;
const modelId = model.id != null ? String(model.id) : '';
if (!modelId) return;
const modelValue = `${category.value}::${modelId}`;
if (!byLabel.has(modelValue)) {
byLabel.set(modelValue, {
thematicCode: category.thematicCode,
thematicName: category.thematicName,
categoryLabel: category.label || gettext('Sans catégorie'),
label,
modelId,
value: modelValue,
});
}
});
modelsByCategoryKey[cacheKey] = Array.from(byLabel.values());
})
.catch(() => {
modelsByCategoryKey[cacheKey] = [];
})
.finally(() => {
delete pendingModelFetches[cacheKey];
renderModelFilters();
});
});
}
function getVisibleModelOptions() {
const modelsByValue = new Map();
const selectedCategories = getSelectedCategoryOptions();
selectedCategories.forEach(category => {
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
(modelsByCategoryKey[cacheKey] || []).forEach(model => {
if (!modelsByValue.has(model.value)) {
modelsByValue.set(model.value, model);
}
});
});
return Array.from(modelsByValue.values()).sort((a, b) => {
const thematicCmp = a.thematicName.localeCompare(b.thematicName, 'fr');
if (thematicCmp !== 0) return thematicCmp;
const categoryCmp = (a.categoryLabel || '').localeCompare((b.categoryLabel || ''), 'fr');
if (categoryCmp !== 0) return categoryCmp;
return a.label.localeCompare(b.label, 'fr');
});
}
function getVisibleConditionalFilterDefinitions() {
const defs = [];
Array.from(activeThematics).forEach(thematicCode => {
const thematicDefs = CONDITIONAL_FILTER_CONFIG[thematicCode] || [];
thematicDefs.forEach(def => {
defs.push({
thematicCode,
thematicName: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].name) || thematicCode,
icon: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].icon) || '',
...def,
});
});
});
return defs;
}
function normalizeSignPanelTypeOption(option) {
const value = option && option.value != null ? String(option.value) : '';
const code = String((option && (option.code || option.label)) || '').trim();
const name = String((option && option.name) || '').trim();
const imageUrl = String((option && option.image_url) || '').trim();
let group = String((option && option.group) || '').trim();
let groupLabel = String((option && option.group_label) || '').trim();
if (!group) {
const firstChar = code ? code[0].toUpperCase() : '';
group = /^[A-Za-z]$/.test(firstChar) ? firstChar : 'Autre';
}
if (!groupLabel) {
groupLabel = group;
}
return { value, code, name, imageUrl, group, groupLabel };
}
function buildSignPanelTypeGroups(options) {
const grouped = new Map();
const groupLabels = new Map();
(options || []).map(normalizeSignPanelTypeOption).forEach(option => {
if (!option.value || !option.code) return;
if (!grouped.has(option.group)) {
grouped.set(option.group, []);
groupLabels.set(option.group, option.groupLabel);
}
grouped.get(option.group).push(option);
});
const definedOrder = ['danger', 'priority', 'prohibition', 'obligation', 'parking', 'indication', 'zone', 'additional', 'other'];
const groupKeys = [];
definedOrder.forEach(key => {
if (grouped.has(key)) {
groupKeys.push(key);
}
});
Array.from(grouped.keys()).forEach(key => {
if (!definedOrder.includes(key)) {
groupKeys.push(key);
}
});
return { grouped, groupLabels, groupKeys };
}
function normalizePanelTypeCodeForImage(code) {
let normalized = String(code || '').trim();
if (!normalized) return '';
const upper = normalized.toUpperCase();
if (upper === 'XA') {
normalized = 'GXA';
} else if (upper === 'XB') {
normalized = 'GXB';
} else if (upper === 'XD') {
normalized = 'GXD';
} else if (upper.startsWith('Z') && upper.length > 1) {
normalized = normalized.slice(1);
}
return normalized.replace(/["']/g, '').trim();
}
function buildPanelTypeImageCandidates(code) {
const normalizedCode = normalizePanelTypeCodeForImage(code);
if (!normalizedCode) return [];
const upperCode = normalizedCode.toUpperCase();
const titleCode = upperCode.charAt(0) + upperCode.slice(1).toLowerCase();
const lowerCode = upperCode.toLowerCase();
const uniqueCodes = Array.from(new Set([upperCode, titleCode, lowerCode]));
const candidates = [];
uniqueCodes.forEach(item => {
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.svg`);
});
uniqueCodes.forEach(item => {
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.png`);
});
return candidates;
}
function appendPanelTypeImage(container, code, imageUrl, options = {}) {
if (!container) return;
const imageHeight = options.height || '40px';
const imageWidth = options.width || '40px';
const objectFit = options.objectFit || 'contain';
function createImage(src) {
const img = document.createElement('img');
img.src = src;
img.alt = code || '';
img.style.maxHeight = '100%';
img.style.maxWidth = '100%';
img.style.height = imageHeight;
img.style.width = imageWidth;
img.style.objectFit = objectFit;
return img;
}
const candidates = buildPanelTypeImageCandidates(code);
let candidateIndex = 0;
function tryFallbackCandidate() {
const src = candidates[candidateIndex++];
if (!src) {
container.textContent = code || '';
return;
}
const fallbackImg = createImage(src);
fallbackImg.onerror = tryFallbackCandidate;
container.innerHTML = '';
container.appendChild(fallbackImg);
}
if (imageUrl) {
const img = createImage(imageUrl);
img.onerror = tryFallbackCandidate;
container.innerHTML = '';
container.appendChild(img);
return;
}
tryFallbackCandidate();
}
function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues) {
const filterName = `${thematicCode}::${def.key}`;
const selectId = `cond-${thematicCode}-${def.key}-select`;
const selectedSet = new Set((selectedValues || []).map(value => String(value)));
const { grouped, groupLabels, groupKeys } = buildSignPanelTypeGroups(def.options || []);
const hiddenOptionsHtml = Array.from(grouped.values()).flat().map(option => {
const isSelected = selectedSet.has(option.value);
return `
<option value="${escapeHtml(option.value)}"
data-code="${escapeHtml(option.code)}"
data-name="${escapeHtml(option.name)}"
data-group="${escapeHtml(option.group)}"
data-image-url="${escapeHtml(option.imageUrl)}"
${isSelected ? 'selected' : ''}>
${escapeHtml(option.code)}
</option>
`;
}).join('');
const styles = `
<style>
#accordion-sign-panel-types .accordion-item {
margin-bottom: 0.5rem;
border: 1px solid rgba(0,0,0,.125) !important;
border-radius: 8px !important;
overflow: hidden;
transition: all 0.2s ease-in-out;
}
#accordion-sign-panel-types .accordion-item:hover {
border-color: #0d6efd !important;
box-shadow: 0 4px 12px rgba(47, 62, 139, 0.06);
}
#accordion-sign-panel-types .accordion-button:not(.collapsed) {
background-color: rgba(13, 110, 253, 0.05) !important;
color: #0d6efd !important;
border-bottom: 1px solid rgba(13, 110, 253, 0.1) !important;
}
#accordion-sign-panel-types .accordion-button {
transition: all 0.2s ease-in-out;
}
#accordion-sign-panel-types .panel-item {
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
border: 1px solid #dee2e6 !important;
}
#accordion-sign-panel-types .panel-item:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(13, 110, 253, 0.15);
border-color: #0d6efd !important;
}
#accordion-sign-panel-types .panel-item.bg-primary {
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.15) !important;
}
</style>
`;
const accordionId = `accordion-sign-panel-types`;
const accordionItemsHtml = groupKeys.map((group, index) => {
const groupLabel = groupLabels.get(group) || group;
const options = grouped.get(group) || [];
const itemsHtml = options.map(option => {
const title = option.name ? `${option.code} - ${option.name}` : option.code;
return `
<div class="panel-item text-center p-1 border rounded bg-white"
data-id="${escapeHtml(option.value)}"
data-code="${escapeHtml(option.code)}"
data-image-url="${escapeHtml(option.imageUrl)}"
style="cursor: pointer; width: 70px;"
title="${escapeHtml(title)}">
<div class="panel-img-placeholder mb-1"
style="height: 50px; display: flex; align-items: center; justify-content: center;"></div>
<div class="small text-truncate fw-bold" style="font-size: 0.7rem;">
${escapeHtml(option.code)}
</div>
</div>
`;
}).join('');
const collapseId = `collapse-sign-${group}`;
const headingId = `heading-sign-${group}`;
return `
<div class="accordion-item border-0 border-bottom">
<h2 class="accordion-header" id="${headingId}">
<button class="accordion-button collapsed py-2 px-3 fw-bold bg-white text-dark shadow-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#${collapseId}"
aria-expanded="false"
aria-controls="${collapseId}"
data-group="${escapeHtml(group)}">
${escapeHtml(groupLabel)}
</button>
</h2>
<div id="${collapseId}" class="accordion-collapse collapse" aria-labelledby="${headingId}" data-bs-parent="#${accordionId}">
<div class="accordion-body p-2 bg-light border-top">
<div class="d-flex flex-wrap gap-2 justify-content-center">
${itemsHtml}
</div>
</div>
</div>
</div>
`;
}).join('');
return `
${styles}
<div class="w-100 mb-2 js-sign-panel-type-picker"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<select name="${escapeHtml(filterName)}"
id="${selectId}"
class="d-none js-sign-panel-type-select"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}"
multiple>
${hiddenOptionsHtml}
</select>
${groupKeys.length ? `
<div class="accordion mb-3 shadow-sm border rounded" id="${accordionId}" style="max-height: 500px; overflow-y: auto;">
${accordionItemsHtml}
</div>
<div class="border rounded p-2 bg-light js-sign-selected-list"
style="max-height: 150px; overflow-y: auto;">
<div class="text-muted small fst-italic empty-message text-center">
${escapeHtml(gettext('Aucun type sélectionné'))}
</div>
</div>
` : `
<div class="text-muted small">${escapeHtml(gettext('Aucun type disponible.'))}</div>
`}
</div>
`;
}
function initSignPanelTypePickers() {
document.querySelectorAll('.js-sign-panel-type-picker').forEach(picker => {
const panelTypeSelect = picker.querySelector('.js-sign-panel-type-select');
if (!panelTypeSelect || panelTypeSelect.dataset.bound === '1') return;
panelTypeSelect.dataset.bound = '1';
const thematicCode = panelTypeSelect.dataset.thematic || picker.dataset.thematic || '';
const filterKey = panelTypeSelect.dataset.filterKey || picker.dataset.filterKey || '';
if (!thematicCode || !filterKey) return;
const accordionButtons = picker.querySelectorAll('.accordion-button');
const selectedList = picker.querySelector('.js-sign-selected-list');
const emptyMessage = selectedList ? selectedList.querySelector('.empty-message') : null;
function getSelectedValues() {
return Array.from(panelTypeSelect.options)
.filter(option => option.selected)
.map(option => String(option.value));
}
function syncFilterState() {
setConditionalFilterValue(thematicCode, filterKey, 'multi', getSelectedValues());
}
function updateSelectedList() {
const selectedOptions = Array.from(panelTypeSelect.options).filter(option => option.selected);
if (selectedList) {
Array.from(selectedList.querySelectorAll('.selected-panel-item')).forEach(item => item.remove());
if (emptyMessage) {
emptyMessage.style.display = selectedOptions.length ? 'none' : 'block';
}
selectedOptions.forEach(option => {
const id = String(option.value);
const code = String(option.dataset.code || option.text || id).trim();
const imageUrl = String(option.dataset.imageUrl || '').trim();
const item = document.createElement('div');
item.className = 'selected-panel-item d-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1';
const content = document.createElement('div');
content.className = 'd-flex align-items-center';
const thumb = document.createElement('div');
thumb.className = 'd-flex align-items-center justify-content-center me-2';
thumb.style.width = '28px';
thumb.style.height = '28px';
appendPanelTypeImage(thumb, code, imageUrl, { width: '28px', height: '28px' });
const label = document.createElement('span');
label.className = 'small fw-bold ms-1';
label.textContent = code;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type';
removeBtn.dataset.id = id;
removeBtn.innerHTML = '<i class="bi bi-x"></i>';
content.appendChild(thumb);
content.appendChild(label);
item.appendChild(content);
item.appendChild(removeBtn);
selectedList.appendChild(item);
});
}
picker.querySelectorAll('.panel-item').forEach(item => {
const id = item.dataset.id;
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
const isSelected = !!(option && option.selected);
item.classList.toggle('bg-primary', isSelected);
item.classList.toggle('text-white', isSelected);
item.classList.toggle('bg-white', !isSelected);
});
syncFilterState();
}
accordionButtons.forEach(btn => {
btn.addEventListener('click', function () {
const group = this.dataset.group;
const collapseDiv = picker.querySelector(`#collapse-sign-${group}`);
if (collapseDiv) {
collapseDiv.querySelectorAll('.panel-item').forEach(item => {
const placeholder = item.querySelector('.panel-img-placeholder');
if (!placeholder || placeholder.children.length > 0) return;
const imageUrl = item.dataset.imageUrl || '';
const code = item.dataset.code || '';
appendPanelTypeImage(placeholder, code, imageUrl);
});
}
});
});
picker.addEventListener('click', function (event) {
const panelItem = event.target.closest('.panel-item');
if (panelItem && picker.contains(panelItem)) {
const id = panelItem.dataset.id;
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
if (option) {
option.selected = !option.selected;
updateSelectedList();
}
return;
}
const removeBtn = event.target.closest('.remove-panel-type');
if (removeBtn && picker.contains(removeBtn)) {
const id = removeBtn.dataset.id;
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
if (option) {
option.selected = false;
updateSelectedList();
}
}
});
updateSelectedList();
});
}
function renderConditionalFilters() {
const container = document.getElementById('conditional-filter-options');
if (!container) return;
pruneConditionalFiltersForActiveThematics();
const defs = getVisibleConditionalFilterDefinitions();
if (!defs.length) {
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
return;
}
const grouped = new Map();
defs.forEach(def => {
if (!grouped.has(def.thematicCode)) grouped.set(def.thematicCode, []);
grouped.get(def.thematicCode).push(def);
});
container.innerHTML = Array.from(grouped.entries()).map(([thematicCode, thematicDefs]) => {
const thematic = THEMATIC_CONFIG[thematicCode] || {};
const heading = thematic.name || thematicCode;
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
const blocks = thematicDefs.map(def => {
const filterName = `${thematicCode}::${def.key}`;
const selected = getConditionalFilterValue(thematicCode, def.key, def.type);
if (thematicCode === 'sign' && def.key === 'panel_type' && def.type === 'multi') {
return renderSignPanelTypeConditionalFilter(thematicCode, def, selected);
}
if (def.type === 'checkbox') {
const inputId = `cond-${thematicCode}-${def.key}`;
const checkedValue = String(def.value || '1');
const isChecked = selected === checkedValue;
return `
<div class="w-100 mb-2">
<div class="filter-options">
<div class="filter-option">
<input type="checkbox"
class="conditional-filter-input"
name="${escapeHtml(filterName)}"
id="${inputId}"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}"
data-filter-type="checkbox"
value="${escapeHtml(checkedValue)}"
${isChecked ? 'checked' : ''}>
<label for="${inputId}">${escapeHtml(def.label || def.key)}</label>
</div>
</div>
</div>
`;
}
if (def.type === 'date') {
const inputId = `cond-${thematicCode}-${def.key}`;
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">
<input type="date"
class="form-control form-control-sm conditional-filter-input"
name="${escapeHtml(filterName)}"
id="${inputId}"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}"
data-filter-type="date"
value="${escapeHtml(selected)}"
style="max-width: 220px;">
</div>
</div>
`;
}
if (def.type === 'select') {
const inputId = `cond-${thematicCode}-${def.key}`;
const optionsHtml = [`<option value="">${escapeHtml(gettext('Tous'))}</option>`].concat(
(def.options || []).map(option => {
const isSelected = selected === String(option.value);
return `<option value="${escapeHtml(String(option.value))}" ${isSelected ? 'selected' : ''}>${escapeHtml(option.label)}</option>`;
})
).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">
<select class="form-select form-select-sm conditional-filter-input"
name="${escapeHtml(filterName)}"
id="${inputId}"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}"
data-filter-type="select"
style="max-width: 420px;">
${optionsHtml}
</select>
</div>
</div>
`;
}
const optionsHtml = (def.options || []).map((option, idx) => {
const optionId = `cond-${thematicCode}-${def.key}-${idx}`;
const inputType = def.type === 'single' ? 'radio' : 'checkbox';
const isChecked = def.type === 'single'
? (selected === String(option.value))
: selected.includes(String(option.value));
return `
<div class="filter-option">
<input type="${inputType}"
class="conditional-filter-input"
name="${escapeHtml(filterName)}"
id="${optionId}"
data-thematic="${escapeHtml(thematicCode)}"
data-filter-key="${escapeHtml(def.key)}"
data-filter-type="${escapeHtml(def.type || 'multi')}"
value="${escapeHtml(String(option.value))}"
${isChecked ? 'checked' : ''}>
<label for="${optionId}">${escapeHtml(option.label)}</label>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">${optionsHtml}</div>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
${blocks}
</div>
`;
}).join('');
initSignPanelTypePickers();
document.querySelectorAll('.conditional-filter-input').forEach(input => {
input.addEventListener('change', function () {
const thematicCode = this.dataset.thematic;
const filterKey = this.dataset.filterKey;
const filterType = this.dataset.filterType || 'multi';
if (filterType === 'single') {
const checked = document.querySelector(`input[name="${escapeCssSelectorValue(`${thematicCode}::${filterKey}`)}"]:checked`);
setConditionalFilterValue(thematicCode, filterKey, 'single', checked ? checked.value : '');
} else if (filterType === 'checkbox') {
setConditionalFilterValue(thematicCode, filterKey, 'single', this.checked ? this.value : '');
} else if (filterType === 'date' || filterType === 'select') {
setConditionalFilterValue(thematicCode, filterKey, 'single', this.value || '');
} else {
const selected = Array.from(document.querySelectorAll(
`input[name="${escapeCssSelectorValue(`${thematicCode}::${filterKey}`)}"]:checked`
)).map(el => el.value);
setConditionalFilterValue(thematicCode, filterKey, 'multi', selected);
}
});
});
}
bindPreventiveSelectionSubmit();
function renderCategoryFilters() {
const container = document.getElementById('category-filter-options');
if (!container) return;
const visibleCategories = getVisibleCategoryOptions();
const visibleValues = new Set(visibleCategories.map(item => item.value));
activeCategories = new Set(Array.from(activeCategories).filter(value => visibleValues.has(value)));
if (!visibleCategories.length) {
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
return;
}
const grouped = new Map();
visibleCategories.forEach(item => {
if (!grouped.has(item.thematicCode)) grouped.set(item.thematicCode, []);
grouped.get(item.thematicCode).push(item);
});
container.innerHTML = Array.from(grouped.entries()).map(([thematicCode, categories]) => {
const thematic = THEMATIC_CONFIG[thematicCode] || {};
const heading = thematic.name || thematicCode;
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
const items = categories.map(category => {
const checkboxId = `category-cb-${thematicCode}-${category.categoryId}`;
const checked = activeCategories.has(category.value) ? 'checked' : '';
return `
<div class="filter-option">
<input type="checkbox" class="category-checkbox"
id="${checkboxId}"
value="${escapeHtml(category.value)}"
${checked}>
<label for="${checkboxId}">${escapeHtml(category.label)}</label>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
<div class="filter-options">${items}</div>
</div>
`;
}).join('');
document.querySelectorAll('.category-checkbox').forEach(cb => {
cb.addEventListener('change', function () {
if (this.checked) activeCategories.add(this.value);
else activeCategories.delete(this.value);
renderModelFilters();
});
});
}
function renderModelFilters() {
const container = document.getElementById('model-filter-options');
const toggleBtn = document.getElementById('model-filter-toggle');
if (!container || !toggleBtn) return;
const modelSection = document.getElementById('model-filter-section');
const sectionButtons = modelSection ? modelSection.querySelector('.filter-section-buttons') : null;
if (sectionButtons && sectionButtons.firstElementChild !== toggleBtn) {
sectionButtons.insertBefore(toggleBtn, sectionButtons.firstChild);
}
if (activeThematics.size === 0) {
container.style.display = '';
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
toggleBtn.classList.add('d-none');
return;
}
if (activeCategories.size === 0) {
container.style.display = '';
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs catégories.")}</span>`;
toggleBtn.classList.add('d-none');
activeModels.clear();
return;
}
ensureModelsLoadedForSelectedCategories();
const selectedCategories = getSelectedCategoryOptions();
const hasPendingFetch = selectedCategories.some(category => {
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
return !!pendingModelFetches[cacheKey];
});
if (hasPendingFetch) {
container.style.display = '';
container.innerHTML = `<span class="text-muted small">${gettext('Chargement des modèles...')}</span>`;
toggleBtn.classList.add('d-none');
return;
}
const visibleModels = getVisibleModelOptions();
const visibleValues = new Set(visibleModels.map(item => item.value));
activeModels = new Set(Array.from(activeModels).filter(value => visibleValues.has(value)));
if (!visibleModels.length) {
container.style.display = '';
container.innerHTML = `<span class="text-muted small">${gettext("Aucun modèle disponible pour les catégories sélectionnées.")}</span>`;
toggleBtn.classList.add('d-none');
return;
}
const shouldCollapse = visibleModels.length > 10;
if (!shouldCollapse) modelFilterCollapsed = false;
toggleBtn.classList.toggle('d-none', !shouldCollapse);
if (shouldCollapse) {
toggleBtn.textContent = modelFilterCollapsed
? gettext('Afficher les modèles')
: gettext('Replier les modèles');
}
if (shouldCollapse && modelFilterCollapsed) {
container.style.display = '';
container.innerHTML = `<span class="text-muted small">${gettext('Bloc replié. Cliquez sur "Afficher les modèles" pour voir les options.')}</span>`;
} else {
container.style.display = '';
const groupedByThematic = new Map();
visibleModels.forEach(item => {
if (!groupedByThematic.has(item.thematicCode)) groupedByThematic.set(item.thematicCode, []);
groupedByThematic.get(item.thematicCode).push(item);
});
container.innerHTML = Array.from(groupedByThematic.entries()).map(([thematicCode, models]) => {
const thematic = THEMATIC_CONFIG[thematicCode] || {};
const heading = thematic.name || thematicCode;
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
const groupedByCategory = new Map();
models.forEach(model => {
const categoryKey = model.categoryLabel || gettext('Sans catégorie');
if (!groupedByCategory.has(categoryKey)) groupedByCategory.set(categoryKey, []);
groupedByCategory.get(categoryKey).push(model);
});
const categoryBlocks = Array.from(groupedByCategory.entries()).map(([categoryLabel, categoryModels]) => {
const items = categoryModels.map(model => {
const checkboxId = `model-cb-${String(model.value).replace(/[^a-zA-Z0-9_-]/g, '-')}`;
const checked = activeModels.has(model.value) ? 'checked' : '';
return `
<div class="filter-option">
<input type="checkbox" class="model-checkbox"
id="${checkboxId}"
value="${escapeHtml(model.value)}"
${checked}>
<label for="${checkboxId}">${escapeHtml(model.label)}</label>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(categoryLabel)}</div>
<div class="filter-options">${items}</div>
</div>
`;
}).join('');
return `
<div class="w-100 mb-2">
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
${categoryBlocks}
</div>
`;
}).join('');
document.querySelectorAll('.model-checkbox').forEach(cb => {
cb.addEventListener('change', function () {
if (this.checked) activeModels.add(this.value);
else activeModels.delete(this.value);
});
});
}
}
// Totaux
function getAssetsForTotals() {
const $table = $('#unified-table');
if (!$table.length || typeof $table.bootstrapTable !== 'function') return [];
const rows = $table.bootstrapTable('getData', {
useCurrentPage: false,
includeHiddenRows: false,
}) || [];
const assetRows = rows.filter(row => !row.is_location);
if (!selectedIds || selectedIds.size === 0) return assetRows;
const selectedAssetRows = assetRows.filter(row => (
selectedIds.has(row.id) || selectedIds.has(String(row.id))
));
return selectedAssetRows.length ? selectedAssetRows : assetRows;
}
function groupByCategoryAndModel(assets) {
const byCat = {};
for (const a of assets) {
const cat = a.category || gettext('Sans catégorie');
const model = a.model || '—';
const lengthValue = Number(a.length ?? a.length_m ?? 0);
const areaValue = Number(a.area ?? a.area_m2 ?? a.surface_area ?? a.surface ?? 0);
byCat[cat] ??= {};
byCat[cat][model] ??= { count: 0, length_m: 0, area_m2: 0 };
byCat[cat][model].count += 1;
byCat[cat][model].length_m += Number.isFinite(lengthValue) ? lengthValue : 0;
byCat[cat][model].area_m2 += Number.isFinite(areaValue) ? areaValue : 0;
}
return byCat;
}
function fmtInt(n) {
return (Math.round(n) || '-').toLocaleString();
}
function renderTotals() {
const contentEl = document.getElementById('totalsContent');
if (!contentEl) return;
const assets = getAssetsForTotals();
const grouped = groupByCategoryAndModel(assets);
const hasSelection = selectedIds && selectedIds.size > 0;
const headerCount = interpolate(
ngettext('%(count)s élément pris en compte', '%(count)s éléments pris en compte', assets.length),
{ count: assets.length },
true
);
const selectionSuffix = hasSelection ? gettext('(sélection)') : gettext('(table)');
let html = `<div class="mb-3 text-muted">${headerCount} ${selectionSuffix}</div>`;
const categories = Object.keys(grouped).sort((a, b) => a.localeCompare(b, 'fr'));
if (!categories.length) {
contentEl.innerHTML = `<div class="text-center text-muted">${gettext('Aucune donnée disponible')}</div>`;
return;
}
for (const cat of categories) {
const models = grouped[cat];
const modelNames = Object.keys(models).sort((a, b) => a.localeCompare(b, 'fr'));
const bodyRows = modelNames.map(m => {
const g = models[m];
return `
<tr>
<td>${m}</td>
<td class="text-end">${fmtInt(g.count)}</td>
<td class="text-end">${fmtInt(g.length_m)}</td>
<td class="text-end">${fmtInt(g.area_m2)}</td>
</tr>
`;
}).join('');
const catTotals = modelNames.reduce((acc, m) => {
acc.count += models[m].count;
acc.length_m += models[m].length_m;
acc.area_m2 += models[m].area_m2;
return acc;
}, { count: 0, length_m: 0, area_m2: 0 });
html += `
<div class="mb-3 pb-2 border-bottom">
<h6 class="mb-2 d-flex align-items-center gap-2">
<span class="badge text-bg-secondary">${cat}</span>
<small class="text-muted">(${fmtInt(catTotals.count)} ${gettext('au total')})</small>
</h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>${gettext('Modèle')}</th>
<th class="text-end">${gettext('Nombre')}</th>
<th class="text-end">${gettext('Longueur (m)')}</th>
<th class="text-end">${gettext('Surface (m²)')}</th>
</tr>
</thead>
<tbody>${bodyRows || `<tr><td colspan="4" class="text-center text-muted">${gettext('Aucun modèle')}</td></tr>`}</tbody>
<tfoot>
<tr class="fw-semibold">
<td>${gettext('Total catégorie')}</td>
<td class="text-end">${fmtInt(catTotals.count)}</td>
<td class="text-end">${fmtInt(catTotals.length_m)}</td>
<td class="text-end">${fmtInt(catTotals.area_m2)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
`;
}
contentEl.innerHTML = html;
}
function initTotalsOverlay() {
const btnTotals = document.getElementById('totals_button');
const overlay = document.getElementById('totalsOverlay');
const closeBtn = document.getElementById('totalsClose');
if (!btnTotals || !overlay || !closeBtn) return;
btnTotals.addEventListener('click', function () {
renderTotals();
overlay.classList.add('show');
setTimeout(() => closeBtn.focus(), 0);
});
function hideOverlay() {
overlay.classList.remove('show');
}
closeBtn.addEventListener('click', hideOverlay);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('show')) {
hideOverlay();
}
});
}
document.addEventListener('DOMContentLoaded', initTotalsOverlay);