1090 lines
37 KiB
JavaScript
1090 lines
37 KiB
JavaScript
let observations_geojson = {};
|
|
let allObservations = [];
|
|
let visibleRows = [];
|
|
|
|
const MAX_ITEMS = 5000;
|
|
const DESCRIPTION_MAX_LENGTH = 60;
|
|
function truncateText(text, limit) {
|
|
if (!text || text.length <= limit) {
|
|
return text;
|
|
}
|
|
return `${text.substring(0, limit - 3).trim()}...`;
|
|
}
|
|
|
|
const STATUS_METADATA = {
|
|
in_preparation: { label: gettext('En préparation'), badge: 'secondary', color: '#6c757d' },
|
|
to_process: { label: gettext('À traiter'), badge: 'danger', color: '#dc3545' },
|
|
in_progress: { label: gettext('En cours'), badge: 'warning', color: '#ffc107' },
|
|
processed: { label: gettext('Traité'), badge: 'success', color: '#198754' },
|
|
not_relevant: { label: gettext('Non relevant'), badge: 'secondary', color: '#6c757d' },
|
|
to_redirect: { label: gettext('À rediriger'), badge: 'warning', color: '#ffc107' },
|
|
duplicate: { label: gettext('Doublon'), badge: 'info', color: '#0dcaf0' },
|
|
closed: { label: gettext('Clôturé'), badge: 'dark', color: '#343a40' },
|
|
archived: { label: gettext('Archivé'), badge: 'dark', color: '#343a40' }
|
|
};
|
|
|
|
function getStatusMetadata(status) {
|
|
return STATUS_METADATA[status] || { label: status || '-', badge: 'secondary', color: '#888888' };
|
|
}
|
|
|
|
const OBSERVATION_TYPE_METADATA = {
|
|
incident: { label: gettext('Incident'), badge: 'danger' },
|
|
inventory: { label: gettext('Inventaire'), badge: 'secondary' },
|
|
remark: { label: gettext('Remarque'), badge: 'info' },
|
|
};
|
|
|
|
function getObservationTypeMetadata(type) {
|
|
return OBSERVATION_TYPE_METADATA[type] || { label: type || '-', badge: 'secondary' };
|
|
}
|
|
|
|
const observationsgeojsonloader = new GeojsonLoader();
|
|
const observations_layer_defs = [
|
|
{
|
|
id: 'observations',
|
|
type: 'circle',
|
|
source: 'observations',
|
|
minzoom: 0,
|
|
maxzoom: 24,
|
|
paint: {
|
|
'circle-radius': [
|
|
'interpolate',
|
|
['linear'],
|
|
['zoom'],
|
|
8, 4,
|
|
14, 8,
|
|
18, 14
|
|
],
|
|
'circle-color': [
|
|
'match',
|
|
['get', 'status'],
|
|
'in_preparation', STATUS_METADATA.in_preparation.color,
|
|
'to_process', STATUS_METADATA.to_process.color,
|
|
'in_progress', STATUS_METADATA.in_progress.color,
|
|
'processed', STATUS_METADATA.processed.color,
|
|
'closed', STATUS_METADATA.closed.color,
|
|
'archived', STATUS_METADATA.archived.color,
|
|
'#007cbf'
|
|
],
|
|
'circle-stroke-color': '#ffffff',
|
|
'circle-stroke-width': 1.5,
|
|
'circle-opacity': 0.85
|
|
}
|
|
}
|
|
];
|
|
|
|
const highlightState = { previousId: null };
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
maploader.addEventListener('mapLoaded', registerMapInteractions, { once: true });
|
|
maploader.loadMap('map');
|
|
|
|
handleGeojsonMapLoading(true, observationsgeojsonloader, 'observations', observations_layer_defs);
|
|
|
|
initObservationsTable();
|
|
setupColumnToggles();
|
|
setupFilterForm();
|
|
setupFilterZoneButton();
|
|
setupLayoutButtons();
|
|
|
|
const query = new URLSearchParams(window.location.search).toString();
|
|
const initialUrl = query ? `${observationsgeojsonUrl}?${query}` : observationsgeojsonUrl;
|
|
observationsgeojsonloader.loadGeojson(initialUrl);
|
|
|
|
// Sync export button with initial URL filters
|
|
_updateExportExcelUrl(query);
|
|
});
|
|
|
|
observationsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
|
|
const geojsonData = event.detail;
|
|
observations_geojson = geojsonData;
|
|
allObservations = geojsonData.features || [];
|
|
visibleRows = [...allObservations];
|
|
|
|
loadDataIntoObservationsTable(allObservations);
|
|
fitMapToObservations(allObservations);
|
|
updateFilterLimitAlert(allObservations.length);
|
|
});
|
|
|
|
observationsgeojsonloader.addEventListener('geojsonError', () => {
|
|
document.getElementById('observations-spinner')?.classList.add('d-none');
|
|
document.getElementById('observations-table-container')?.classList.remove('d-none');
|
|
updateResultCount(0);
|
|
});
|
|
|
|
function registerMapInteractions(event) {
|
|
const mapInstance = event.detail;
|
|
mapInstance.addControl(new maplibregl.NavigationControl(), 'top-right');
|
|
|
|
// Contrôle photo aérienne
|
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
|
const layerSwitcher = new LayerSwitcherControl({
|
|
layers: [
|
|
{
|
|
id: 'ortho',
|
|
name: 'Orthophotos',
|
|
icon: 'satellite',
|
|
group: 'ortho',
|
|
beforeLayer: 'observations',
|
|
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'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
mapInstance.addControl(layerSwitcher, 'top-left');
|
|
}
|
|
|
|
mapInstance.on('click', 'observations', (e) => {
|
|
const feature = e.features?.[0];
|
|
if (!feature) {
|
|
return;
|
|
}
|
|
|
|
highlightMapFeature(mapInstance, 'observations', feature.id, highlightState);
|
|
|
|
const props = feature.properties || {};
|
|
const statusMeta = getStatusMetadata(props.status);
|
|
const detailUrl = `${observationsIndexUrl}${props.id}/`;
|
|
const popupContent = `
|
|
<div class="popup-scrollable">
|
|
<strong><a href="${detailUrl}">${props.description || gettext('Observation')}</a></strong>
|
|
<div class="mb-1">${props.address || ''}</div>
|
|
<div class="mb-1"><span class="badge text-bg-${statusMeta.badge}">${props.status_label || statusMeta.label}</span></div>
|
|
<div class="text-muted small">${dateFormatter(props.created_at)}</div>
|
|
</div>
|
|
`;
|
|
|
|
new maplibregl.Popup({ closeButton: true })
|
|
.setLngLat(e.lngLat)
|
|
.setHTML(popupContent)
|
|
.addTo(mapInstance);
|
|
});
|
|
|
|
mapInstance.on('mouseenter', 'observations', () => {
|
|
mapInstance.getCanvas().style.cursor = 'pointer';
|
|
});
|
|
|
|
mapInstance.on('mouseleave', 'observations', () => {
|
|
mapInstance.getCanvas().style.cursor = '';
|
|
});
|
|
}
|
|
|
|
function initObservationsTable() {
|
|
const $table = $('#observations-table');
|
|
if (!$table.length) {
|
|
return;
|
|
}
|
|
|
|
function calculateTableHeight() {
|
|
const windowHeight = $(window).height();
|
|
const offset = 260;
|
|
return Math.max(360, windowHeight - offset);
|
|
}
|
|
|
|
// Prepare initial sort from saved preferences
|
|
const initialSort = {};
|
|
if (typeof SAVED_SORT !== 'undefined' && SAVED_SORT.sortName) {
|
|
initialSort.sortName = SAVED_SORT.sortName;
|
|
initialSort.sortOrder = SAVED_SORT.sortOrder || 'asc';
|
|
} else {
|
|
initialSort.sortName = 'created_at';
|
|
initialSort.sortOrder = 'desc';
|
|
}
|
|
|
|
$table.bootstrapTable({
|
|
columns: [
|
|
{ field: 'actions', title: '', formatter: 'actionsFormatter', class: 'text-nowrap' },
|
|
{ field: 'code', title: gettext('Code'), formatter: 'codeFormatter', class: 'text-nowrap', sortable: true },
|
|
{ field: 'thematic', title: gettext('Thématique'), formatter: 'thematicFormatter', class: 'text-nowrap', sortable: true },
|
|
{ field: 'category', title: gettext('Catégorie'), sortable: true },
|
|
{ field: 'symptom', title: gettext('Symptôme'), sortable: true },
|
|
{ field: 'observation_type_label', title: gettext('Type'), formatter: 'observationTypeFormatter', class: 'text-nowrap', sortable: true },
|
|
{ field: 'status_label', title: gettext('Statut'), formatter: 'statusFormatter', class: 'text-nowrap', sortable: true },
|
|
{ field: 'created_at', title: gettext('Date'), formatter: 'dateFormatter', class: 'text-nowrap', sortable: true },
|
|
{ field: 'description', title: gettext('Description'), formatter: 'descriptionFormatter', sortable: true },
|
|
{ field: 'address', title: gettext('Adresse'), sortable: true }
|
|
],
|
|
height: calculateTableHeight(),
|
|
...initialSort
|
|
});
|
|
|
|
// Restore saved column visibility
|
|
_restoreColumnVisibility($table);
|
|
|
|
// Save column visibility when toggled
|
|
$table.on('column-switch.bs.table', function () {
|
|
setTimeout(function () {
|
|
_saveColumnVisibility($table);
|
|
$table.bootstrapTable('resetView');
|
|
}, 0);
|
|
});
|
|
|
|
// Save sort preferences when sort changes
|
|
$table.on('sort.bs.table', function () {
|
|
setTimeout(savePreferencesFromUrl, 500);
|
|
});
|
|
|
|
registerZoomAction('#observations-table', '.zoom-btn', map, 16);
|
|
|
|
$table.on('post-body.bs.table', () => {
|
|
visibleRows = $('#observations-table').bootstrapTable('getData');
|
|
syncMapWithTable();
|
|
});
|
|
|
|
$(window).on('resize', () => {
|
|
$table.bootstrapTable('resetView', { height: calculateTableHeight() });
|
|
});
|
|
}
|
|
|
|
function loadDataIntoObservationsTable(features) {
|
|
const tableData = features.map((feature) => {
|
|
const props = feature.properties || {};
|
|
const coords = feature.geometry?.coordinates || [];
|
|
const statusMeta = getStatusMetadata(props.status);
|
|
return {
|
|
actions: '',
|
|
id: props.id || feature.id,
|
|
code: props.code || '',
|
|
description: props.description || '-',
|
|
thematic: props.thematic || '-',
|
|
thematic_icon: props.thematic_icon || '',
|
|
observation_type: props.observation_type || '',
|
|
observation_type_label: props.observation_type_label || '-',
|
|
status: props.status || '',
|
|
status_label: props.status_label || statusMeta.label,
|
|
status_badge: statusMeta.badge,
|
|
category: props.category || '-',
|
|
symptom: props.symptom || '-',
|
|
address: props.address || '-',
|
|
created_at: props.created_at || '',
|
|
lon: coords[0] ?? null,
|
|
lat: coords[1] ?? null
|
|
};
|
|
});
|
|
|
|
$('#observations-spinner').addClass('d-none');
|
|
$('#observations-table-container').removeClass('d-none');
|
|
$('#observations-table').bootstrapTable('load', tableData);
|
|
|
|
visibleRows = tableData;
|
|
syncMapWithTable();
|
|
updateResultCount(tableData.length);
|
|
}
|
|
|
|
function updateFilterLimitAlert(count) {
|
|
const alertEl = document.getElementById('filter-limit-alert');
|
|
if (!alertEl) {
|
|
return;
|
|
}
|
|
|
|
const reached = count >= MAX_ITEMS;
|
|
alertEl.classList.toggle('d-none', !reached);
|
|
if (reached) {
|
|
alertEl.title = interpolate(gettext('Résultat tronqué à 5 000 éléments (%(count)s reçus).'), { count }, true);
|
|
} else {
|
|
alertEl.removeAttribute('title');
|
|
}
|
|
}
|
|
|
|
function updateResultCount(count) {
|
|
const target = document.getElementById('observations-result-count');
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
if (!count) {
|
|
target.textContent = gettext('Aucune observation chargée');
|
|
return;
|
|
}
|
|
|
|
const msg = ngettext('%(count)s observation chargée', '%(count)s observations chargées', count);
|
|
target.textContent = interpolate(msg, { count }, true);
|
|
}
|
|
|
|
function fitMapToObservations(features) {
|
|
if (!map || !features.length) {
|
|
return;
|
|
}
|
|
|
|
const bounds = new maplibregl.LngLatBounds();
|
|
features.forEach((feature) => {
|
|
const coords = feature.geometry?.coordinates;
|
|
if (coords && coords.length === 2) {
|
|
bounds.extend(coords);
|
|
}
|
|
});
|
|
|
|
if (!bounds.isEmpty()) {
|
|
map.fitBounds(bounds, { padding: 50 });
|
|
}
|
|
}
|
|
|
|
function setupFilterForm() {
|
|
const form = document.getElementById('filter-form');
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
// Apply filters button
|
|
document.getElementById('apply-filters')?.addEventListener('click', () => {
|
|
const params = buildFilterParams(new FormData(form));
|
|
_applySearchToParams(params);
|
|
savePreferencesFromForm(params);
|
|
refreshListing(params);
|
|
|
|
const filterModal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
|
|
if (filterModal) filterModal.hide();
|
|
});
|
|
|
|
// Reset filters button
|
|
document.getElementById('reset-filters')?.addEventListener('click', () => {
|
|
// Uncheck all filter checkboxes
|
|
form.querySelectorAll('input[type="checkbox"][name="thematic"]').forEach(cb => { cb.checked = false; });
|
|
form.querySelectorAll('input[type="checkbox"][name="category"]').forEach(cb => { cb.checked = false; });
|
|
form.querySelectorAll('input[type="checkbox"][name="symptom"]').forEach(cb => { cb.checked = false; });
|
|
// Reset type checkboxes: check all except 'inventory'
|
|
form.querySelectorAll('input[type="checkbox"][name="type"]').forEach(cb => {
|
|
cb.checked = cb.value !== 'inventory';
|
|
});
|
|
// Reset status checkboxes: check all except 'archived'
|
|
form.querySelectorAll('input[type="checkbox"][name="status"]').forEach(cb => {
|
|
cb.checked = cb.value !== 'archived';
|
|
});
|
|
// Reset dates and other fields
|
|
const dateFrom = form.querySelector('input[name="date_from"]');
|
|
const dateTo = form.querySelector('input[name="date_to"]');
|
|
if (dateFrom) dateFrom.value = '';
|
|
if (dateTo) dateTo.value = '';
|
|
const mineCheck = form.querySelector('input[name="mine"]');
|
|
const teamCheck = form.querySelector('input[name="team"]');
|
|
if (mineCheck) mineCheck.checked = false;
|
|
if (teamCheck) teamCheck.checked = false;
|
|
|
|
// Show all category and symptom options after reset — collapse sub-category groups
|
|
document.querySelectorAll('.filter-options[data-field="category"] .filter-option[data-parent-id]:not([data-parent-id=""])').forEach(ch => {
|
|
ch.style.display = 'none';
|
|
});
|
|
document.querySelectorAll('.filter-subcat-controls').forEach(c => { c.style.display = 'none'; });
|
|
filterCategoriesByThematics();
|
|
filterSymptomsByThematicsAndCategories();
|
|
|
|
const params = buildFilterParams(new FormData(form));
|
|
_applySearchToParams(params);
|
|
|
|
// Save empty preferences on reset
|
|
if (typeof savePreferencesUrl !== 'undefined') {
|
|
fetch(savePreferencesUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
|
|
body: JSON.stringify({ filters: {}, sort: {} })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
refreshListing(params);
|
|
|
|
const filterModal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
|
|
if (filterModal) filterModal.hide();
|
|
});
|
|
|
|
// Select-all / deselect-all buttons
|
|
document.querySelectorAll('.select-all-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const field = this.getAttribute('data-field');
|
|
const container = document.querySelector(`.filter-options[data-field="${field}"]`);
|
|
if (container) {
|
|
container.querySelectorAll('.filter-option:not([style*="display: none"]) input[type="checkbox"]').forEach(cb => {
|
|
cb.checked = true;
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.deselect-all-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const field = this.getAttribute('data-field');
|
|
const container = document.querySelector(`.filter-options[data-field="${field}"]`);
|
|
if (container) {
|
|
container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
cb.checked = false;
|
|
});
|
|
if (field === 'thematic') {
|
|
filterCategoriesByThematics();
|
|
filterSymptomsByThematicsAndCategories();
|
|
}
|
|
if (field === 'category') {
|
|
filterSymptomsByThematicsAndCategories();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Thematic → category → symptom chain
|
|
document.querySelectorAll('input[name="thematic"]').forEach(cb => {
|
|
cb.addEventListener('change', () => {
|
|
filterCategoriesByThematics();
|
|
filterSymptomsByThematicsAndCategories();
|
|
});
|
|
});
|
|
document.querySelectorAll('input[name="category"]').forEach(cb => {
|
|
cb.addEventListener('change', function() {
|
|
const option = this.closest('.filter-option');
|
|
const catId = option ? option.getAttribute('data-category-id') : null;
|
|
const parentId = option ? option.getAttribute('data-parent-id') : null;
|
|
if (catId && !parentId) {
|
|
// Parent toggled: show/hide (aucune) chip, children + auto-check on select
|
|
const container = document.querySelector('.filter-options[data-field="category"]');
|
|
if (container) {
|
|
const controls = container.querySelector(`.filter-subcat-controls[data-for-parent="${catId}"]`);
|
|
if (controls) controls.style.display = this.checked ? '' : 'none';
|
|
const noneDiv = container.querySelector(`.filter-option[data-category-id="${catId}_none"]`);
|
|
if (noneDiv) {
|
|
noneDiv.style.display = this.checked ? '' : 'none';
|
|
const noneCb = noneDiv.querySelector('input[type="checkbox"]');
|
|
if (noneCb) noneCb.checked = this.checked;
|
|
}
|
|
container.querySelectorAll(`.filter-option[data-parent-id="${catId}"]:not([data-category-id="${catId}_none"])`).forEach(childOption => {
|
|
if (this.checked) {
|
|
if (option.style.display !== 'none') {
|
|
childOption.style.display = '';
|
|
const childCb = childOption.querySelector('input[type="checkbox"]');
|
|
if (childCb) childCb.checked = true;
|
|
}
|
|
} else {
|
|
childOption.style.display = 'none';
|
|
const childCb = childOption.querySelector('input[type="checkbox"]');
|
|
if (childCb) childCb.checked = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
filterSymptomsByThematicsAndCategories();
|
|
});
|
|
});
|
|
|
|
// Create (aucune) chip + Tous/Aucun buttons + init visibility for sub-category groups
|
|
const catContainer = document.querySelector('.filter-options[data-field="category"]');
|
|
if (catContainer) {
|
|
const parentIds = new Set();
|
|
catContainer.querySelectorAll('.filter-option[data-parent-id]:not([data-parent-id=""])').forEach(opt => {
|
|
parentIds.add(opt.getAttribute('data-parent-id'));
|
|
});
|
|
|
|
const urlCategories = new URLSearchParams(window.location.search).getAll('category');
|
|
|
|
parentIds.forEach(parentId => {
|
|
const allChildren = Array.from(catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`));
|
|
if (!allChildren.length) return;
|
|
|
|
const parentOption = catContainer.querySelector(`.filter-option[data-category-id="${parentId}"]`);
|
|
const parentCb = parentOption ? parentOption.querySelector('input[type="checkbox"]') : null;
|
|
|
|
// Parent checkbox becomes a UI toggle only — don't submit its value
|
|
if (parentCb) parentCb.name = 'category_toggle';
|
|
|
|
// Add (aucune sous-catégorie) chip before first real child
|
|
const noneId = `category_none_${parentId}`;
|
|
const noneDiv = document.createElement('div');
|
|
noneDiv.className = 'filter-option subcategory-option';
|
|
noneDiv.setAttribute('data-parent-id', parentId);
|
|
noneDiv.setAttribute('data-category-id', `${parentId}_none`);
|
|
noneDiv.setAttribute('data-thematic', parentOption ? parentOption.getAttribute('data-thematic') : '');
|
|
noneDiv.innerHTML = `<input type="checkbox" name="category" id="${noneId}" value="${parentId}">` +
|
|
`<label for="${noneId}"><i class="bi bi-slash-circle me-1 text-muted small"></i>${typeof subcatNoneLabel !== 'undefined' ? subcatNoneLabel : '(aucune sous-catégorie)'}</label>`;
|
|
allChildren[0].insertAdjacentElement('beforebegin', noneDiv);
|
|
|
|
// Determine if group should be expanded on page load
|
|
const anyChildSelected = allChildren.some(ch => {
|
|
const cb = ch.querySelector('input[type="checkbox"]');
|
|
return cb && urlCategories.includes(cb.value);
|
|
});
|
|
const parentDirectlySelected = urlCategories.includes(parentId);
|
|
const expand = anyChildSelected || parentDirectlySelected;
|
|
|
|
if (expand) {
|
|
if (parentCb) parentCb.checked = true;
|
|
noneDiv.style.display = '';
|
|
if (parentDirectlySelected) noneDiv.querySelector('input').checked = true;
|
|
allChildren.forEach(ch => { ch.style.display = ''; });
|
|
} else {
|
|
noneDiv.style.display = 'none';
|
|
allChildren.forEach(ch => {
|
|
ch.style.display = 'none';
|
|
const cb = ch.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = false;
|
|
});
|
|
}
|
|
|
|
if (allChildren[0]) allChildren[0].style.marginLeft = '0.5rem';
|
|
|
|
// Tous/Aucun controls after last child
|
|
const controls = document.createElement('div');
|
|
controls.className = 'filter-subcat-controls';
|
|
controls.setAttribute('data-for-parent', parentId);
|
|
controls.style.display = expand ? '' : 'none';
|
|
controls.style.marginLeft = '0.5rem';
|
|
controls.innerHTML = `<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 subcat-select-all" data-parent="${parentId}">Tous</button>` +
|
|
`<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 subcat-deselect-all" data-parent="${parentId}">Aucun</button>`;
|
|
allChildren[allChildren.length - 1].insertAdjacentElement('afterend', controls);
|
|
|
|
controls.querySelector('.subcat-select-all').addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`).forEach(opt => {
|
|
if (opt.style.display !== 'none') {
|
|
const cb = opt.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = true;
|
|
}
|
|
});
|
|
});
|
|
controls.querySelector('.subcat-deselect-all').addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`).forEach(opt => {
|
|
const cb = opt.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = false;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize filters on load
|
|
filterCategoriesByThematics();
|
|
filterSymptomsByThematicsAndCategories();
|
|
}
|
|
|
|
function filterCategoriesByThematics() {
|
|
const checkedThematics = Array.from(document.querySelectorAll('input[name="thematic"]:checked')).map(cb => cb.value);
|
|
const categoryOptions = document.querySelectorAll('.filter-options[data-field="category"] .filter-option');
|
|
|
|
categoryOptions.forEach(option => {
|
|
const parentId = option.getAttribute('data-parent-id');
|
|
if (parentId) {
|
|
// Sub-category: only show if parent is visible AND checked
|
|
const parentOption = document.querySelector(`.filter-options[data-field="category"] .filter-option[data-category-id="${parentId}"]`);
|
|
const parentVisible = parentOption && parentOption.style.display !== 'none';
|
|
const parentChecked = parentOption && parentOption.querySelector('input[type="checkbox"]')?.checked;
|
|
if (!parentVisible || !parentChecked) {
|
|
option.style.display = 'none';
|
|
const cb = option.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = false;
|
|
}
|
|
return;
|
|
}
|
|
// Parent category: show/hide based on thematic
|
|
if (checkedThematics.length === 0) {
|
|
option.style.display = '';
|
|
return;
|
|
}
|
|
const optThematic = option.getAttribute('data-thematic');
|
|
if (optThematic && checkedThematics.includes(optThematic)) {
|
|
option.style.display = '';
|
|
} else if (optThematic) {
|
|
option.style.display = 'none';
|
|
const cb = option.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = false;
|
|
// Hide children and controls too
|
|
const catId = option.getAttribute('data-category-id');
|
|
if (catId) {
|
|
const controls = document.querySelector(`.filter-subcat-controls[data-for-parent="${catId}"]`);
|
|
if (controls) controls.style.display = 'none';
|
|
document.querySelectorAll(`.filter-options[data-field="category"] .filter-option[data-parent-id="${catId}"]`).forEach(child => {
|
|
child.style.display = 'none';
|
|
const childCb = child.querySelector('input[type="checkbox"]');
|
|
if (childCb) childCb.checked = false;
|
|
});
|
|
}
|
|
} else {
|
|
option.style.display = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function filterSymptomsByThematicsAndCategories() {
|
|
const checkedThematics = Array.from(document.querySelectorAll('input[name="thematic"]:checked')).map(cb => cb.value);
|
|
const checkedCategories = Array.from(document.querySelectorAll('input[name="category"]:checked')).map(cb => cb.value);
|
|
const symptomOptions = document.querySelectorAll('.filter-options[data-field="symptom"] .filter-option');
|
|
|
|
symptomOptions.forEach(option => {
|
|
const optThematic = option.getAttribute('data-thematic') || '';
|
|
const optCats = (option.getAttribute('data-categories') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
|
|
const matchesThematic = !checkedThematics.length || (optThematic && checkedThematics.includes(optThematic));
|
|
|
|
let matchesCategory;
|
|
if (!checkedCategories.length) {
|
|
matchesCategory = true;
|
|
} else if (!optCats.length) {
|
|
matchesCategory = true;
|
|
} else {
|
|
matchesCategory = optCats.some(c => checkedCategories.includes(c));
|
|
}
|
|
|
|
if (matchesThematic && matchesCategory) {
|
|
option.style.display = '';
|
|
} else {
|
|
option.style.display = 'none';
|
|
const cb = option.querySelector('input[type="checkbox"]');
|
|
if (cb) cb.checked = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildFilterParams(formData) {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of formData.entries()) {
|
|
if (value) {
|
|
params.append(key, value);
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function refreshListing(params) {
|
|
const queryString = params.toString();
|
|
const targetUrl = queryString ? `${observationsgeojsonUrl}?${queryString}` : observationsgeojsonUrl;
|
|
|
|
window.history.pushState({}, '', queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname);
|
|
|
|
_updateExportExcelUrl(queryString);
|
|
|
|
$('#observations-spinner').removeClass('d-none');
|
|
$('#observations-table-container').addClass('d-none');
|
|
|
|
handleGeojsonMapLoading(false, observationsgeojsonloader, 'observations');
|
|
observationsgeojsonloader.loadGeojson(targetUrl);
|
|
}
|
|
|
|
function _updateExportExcelUrl(queryString) {
|
|
const btn = document.getElementById('export-excel-btn');
|
|
if (!btn || typeof observationsExportExcelUrl === 'undefined') return;
|
|
btn.href = queryString ? `${observationsExportExcelUrl}?${queryString}` : observationsExportExcelUrl;
|
|
}
|
|
|
|
// === Search widget ===
|
|
|
|
function _applySearchToParams(params) {
|
|
const q = $('#observations-search-input').val().trim();
|
|
if (q) {
|
|
params.set('q', q);
|
|
params.set('search_mode', document.querySelector('input[name="obs-search-mode"]:checked')?.value || 'and');
|
|
} else {
|
|
params.delete('q');
|
|
params.delete('search_mode');
|
|
}
|
|
}
|
|
|
|
function _buildObsSearchParams() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
_applySearchToParams(params);
|
|
return params;
|
|
}
|
|
|
|
function runObservationSearch() {
|
|
const params = _buildObsSearchParams();
|
|
const hasSearch = !!$('#observations-search-input').val().trim();
|
|
$('#observations-search-clear').css('visibility', hasSearch ? 'visible' : 'hidden');
|
|
refreshListing(params);
|
|
setTimeout(savePreferencesFromUrl, 500);
|
|
}
|
|
|
|
$('#observations-search-btn').on('click', function () {
|
|
runObservationSearch();
|
|
});
|
|
|
|
$('#observations-search-input').on('keydown', function (e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
runObservationSearch();
|
|
} else if (e.key === 'Escape') {
|
|
$(this).val('');
|
|
runObservationSearch();
|
|
}
|
|
});
|
|
|
|
$('#observations-search-clear').on('click', function () {
|
|
$('#observations-search-input').val('');
|
|
$('#observations-search-clear').css('visibility', 'hidden');
|
|
runObservationSearch();
|
|
});
|
|
|
|
window.addEventListener('load', function () {
|
|
const _urlParams = new URLSearchParams(window.location.search);
|
|
const _urlQ = _urlParams.get('q') || '';
|
|
if (_urlQ) {
|
|
$('#observations-search-input').val(_urlQ);
|
|
$('#observations-search-clear').css('visibility', 'visible');
|
|
const _urlMode = _urlParams.get('search_mode') || 'and';
|
|
const _radio = document.querySelector(`input[name="obs-search-mode"][value="${_urlMode}"]`);
|
|
if (_radio) _radio.checked = true;
|
|
}
|
|
});
|
|
|
|
function setupFilterZoneButton() {
|
|
const button = document.getElementById('filterzone');
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
button.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
if (!map || !observations_geojson.features) {
|
|
return;
|
|
}
|
|
filterTableByMapBounds(map, observations_geojson, loadDataIntoObservationsTable);
|
|
});
|
|
}
|
|
|
|
function setupColumnToggles() {
|
|
const colLeft = document.getElementById('col-left');
|
|
const colRight = document.getElementById('col-right');
|
|
const btnLeft = document.getElementById('expand-col-left');
|
|
const btnRight = document.getElementById('expand-col-right');
|
|
|
|
if (!colLeft || !colRight || !btnLeft || !btnRight) {
|
|
return;
|
|
}
|
|
|
|
const iconLeft = btnLeft.querySelector('i');
|
|
const iconRight = btnRight.querySelector('i');
|
|
|
|
function resetColumns() {
|
|
colLeft.classList.remove('col-11', 'order-2');
|
|
colRight.classList.remove('col-11', 'order-2');
|
|
colLeft.classList.add('col-md-6');
|
|
colRight.classList.add('col-md-6');
|
|
|
|
btnLeft.setAttribute('aria-expanded', 'false');
|
|
btnRight.setAttribute('aria-expanded', 'false');
|
|
|
|
iconLeft.classList.remove('bi-box-arrow-in-left');
|
|
iconLeft.classList.add('bi-box-arrow-in-right');
|
|
iconRight.classList.remove('bi-box-arrow-in-right');
|
|
iconRight.classList.add('bi-box-arrow-in-left');
|
|
|
|
$('#observations-table').bootstrapTable('resetView');
|
|
map?.resize();
|
|
}
|
|
|
|
btnLeft.addEventListener('click', () => {
|
|
const expanded = btnLeft.getAttribute('aria-expanded') === 'true';
|
|
if (!expanded) {
|
|
colLeft.classList.remove('col-md-6', 'order-2');
|
|
colRight.classList.remove('col-md-6', 'order-2');
|
|
colLeft.classList.add('col-11');
|
|
colRight.classList.add('col-11', 'order-2');
|
|
|
|
btnLeft.setAttribute('aria-expanded', 'true');
|
|
btnRight.setAttribute('aria-expanded', 'false');
|
|
|
|
iconLeft.classList.remove('bi-box-arrow-in-right');
|
|
iconLeft.classList.add('bi-box-arrow-in-left');
|
|
iconRight.classList.remove('bi-box-arrow-in-right');
|
|
iconRight.classList.add('bi-box-arrow-in-left');
|
|
|
|
$('#observations-table').bootstrapTable('resetView');
|
|
} else {
|
|
resetColumns();
|
|
}
|
|
});
|
|
|
|
btnRight.addEventListener('click', () => {
|
|
const expanded = btnRight.getAttribute('aria-expanded') === 'true';
|
|
if (!expanded) {
|
|
colLeft.classList.remove('col-md-6', 'order-2');
|
|
colRight.classList.remove('col-md-6', 'order-2');
|
|
colRight.classList.add('col-11');
|
|
colLeft.classList.add('col-11', 'order-2');
|
|
|
|
btnRight.setAttribute('aria-expanded', 'true');
|
|
btnLeft.setAttribute('aria-expanded', 'false');
|
|
|
|
iconRight.classList.remove('bi-box-arrow-in-left');
|
|
iconRight.classList.add('bi-box-arrow-in-right');
|
|
iconLeft.classList.remove('bi-box-arrow-in-left');
|
|
iconLeft.classList.add('bi-box-arrow-in-right');
|
|
|
|
map?.resize();
|
|
} else {
|
|
resetColumns();
|
|
}
|
|
});
|
|
}
|
|
|
|
function syncMapWithTable() {
|
|
if (!map || !map.getLayer('observations')) {
|
|
return;
|
|
}
|
|
|
|
if (!visibleRows.length || visibleRows.length === allObservations.length) {
|
|
map.setFilter('observations', null);
|
|
return;
|
|
}
|
|
|
|
const ids = visibleRows.map((row) => row.id);
|
|
if (!ids.length) {
|
|
map.setFilter('observations', ['==', ['get', 'id'], '__none__']);
|
|
return;
|
|
}
|
|
|
|
map.setFilter('observations', ['match', ['get', 'id'], ['literal', ids], true, false]);
|
|
}
|
|
|
|
function actionsFormatter(value, row) {
|
|
let html = '';
|
|
|
|
if (row.lon && row.lat) {
|
|
html += `
|
|
<button class="btn btn-sm btn-light zoom-btn" data-lon="${row.lon}" data-lat="${row.lat}" title="${gettext('Zoomer sur la carte')}">
|
|
<i class="bi bi-geo-alt"></i>
|
|
</button>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function codeFormatter(value, row) {
|
|
const label = value || '-';
|
|
return `<a href="${observationsIndexUrl}${row.id}/" class="fw-semibold text-decoration-none text-nowrap">${label}</a>`;
|
|
}
|
|
|
|
function descriptionFormatter(value, row) {
|
|
const fallback = gettext('Observation');
|
|
const safeValue = value && value.trim().length ? value.trim() : fallback;
|
|
const truncated = truncateText(safeValue, DESCRIPTION_MAX_LENGTH);
|
|
// return `<a href="${observationsIndexUrl}${row.id}/" class="fw-semibold text-decoration-none" title="${safeValue}">${truncated}</a>`;
|
|
return truncated;
|
|
}
|
|
|
|
function thematicFormatter(value, row) {
|
|
if (!value || value === '-') {
|
|
return '-';
|
|
}
|
|
|
|
const icon = row.thematic_icon ? `<i class="bi ${row.thematic_icon} me-1"></i>` : '';
|
|
return `${icon}${value}`;
|
|
}
|
|
|
|
function statusFormatter(value, row) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
const meta = getStatusMetadata(value);
|
|
const label = row.status_label || meta.label;
|
|
return `<span class="badge text-bg-${row.status_badge || meta.badge}">${label}</span>`;
|
|
}
|
|
|
|
function observationTypeFormatter(value, row) {
|
|
const meta = getObservationTypeMetadata(row.observation_type);
|
|
const label = value || meta.label;
|
|
return `<span class="badge text-bg-${meta.badge}">${label}</span>`;
|
|
}
|
|
|
|
window.actionsFormatter = actionsFormatter;
|
|
window.codeFormatter = codeFormatter;
|
|
window.descriptionFormatter = descriptionFormatter;
|
|
window.thematicFormatter = thematicFormatter;
|
|
window.statusFormatter = statusFormatter;
|
|
window.observationTypeFormatter = observationTypeFormatter;
|
|
|
|
// Layout switcher
|
|
function setupLayoutButtons() {
|
|
const btnTable = document.getElementById('layout-btn-table');
|
|
const btnBoth = document.getElementById('layout-btn-both');
|
|
const btnMap = document.getElementById('layout-btn-map');
|
|
const colLeft = document.getElementById('col-left');
|
|
const colRight = document.getElementById('col-right');
|
|
|
|
if (!btnTable || !btnBoth || !btnMap || !colLeft || !colRight) {
|
|
return;
|
|
}
|
|
|
|
function setLayout(layout) {
|
|
// Reset button states
|
|
[btnTable, btnBoth, btnMap].forEach(btn => btn.classList.remove('text-primary'));
|
|
|
|
if (layout === 'table_only') {
|
|
btnTable.classList.add('text-primary');
|
|
colLeft.classList.remove('d-none', 'col-md-6');
|
|
colLeft.classList.add('col-12');
|
|
colRight.classList.add('d-none');
|
|
} else if (layout === 'map_only') {
|
|
btnMap.classList.add('text-primary');
|
|
colLeft.classList.add('d-none');
|
|
colRight.classList.remove('d-none', 'col-md-6');
|
|
colRight.classList.add('col-12');
|
|
} else {
|
|
// both (default)
|
|
btnBoth.classList.add('text-primary');
|
|
colLeft.classList.remove('d-none', 'col-12');
|
|
colLeft.classList.add('col-md-6');
|
|
colRight.classList.remove('d-none', 'col-12');
|
|
colRight.classList.add('col-md-6');
|
|
}
|
|
|
|
// Save preference in localStorage
|
|
localStorage.setItem('observations_layout', layout);
|
|
|
|
// Resize map and table
|
|
setTimeout(() => {
|
|
maploader.map?.resize();
|
|
$('#observations-table').bootstrapTable('resetView');
|
|
}, 100);
|
|
}
|
|
|
|
btnTable.addEventListener('click', () => setLayout('table_only'));
|
|
btnBoth.addEventListener('click', () => setLayout('both'));
|
|
btnMap.addEventListener('click', () => setLayout('map_only'));
|
|
|
|
// Restore saved layout
|
|
const savedLayout = localStorage.getItem('observations_layout') || 'both';
|
|
setLayout(savedLayout);
|
|
}
|
|
|
|
// ==================== Preferences Management ====================
|
|
|
|
function getCookie(name) {
|
|
let cookieValue = null;
|
|
if (document.cookie && document.cookie !== '') {
|
|
const cookies = document.cookie.split(';');
|
|
for (let i = 0; i < cookies.length; i++) {
|
|
const cookie = cookies[i].trim();
|
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return cookieValue;
|
|
}
|
|
|
|
const OBS_COLUMNS_KEY = 'observations_columns_hidden';
|
|
|
|
function _saveColumnVisibility($table) {
|
|
try {
|
|
const allCols = $table.bootstrapTable('getOptions').columns[0] || [];
|
|
const visibleFields = $table.bootstrapTable('getVisibleColumns').map((c) => c.field);
|
|
const hiddenFields = allCols
|
|
.filter((c) => c.field && c.field !== 'actions' && !visibleFields.includes(c.field))
|
|
.map((c) => c.field);
|
|
localStorage.setItem(OBS_COLUMNS_KEY, JSON.stringify(hiddenFields));
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function _restoreColumnVisibility($table) {
|
|
try {
|
|
const raw = localStorage.getItem(OBS_COLUMNS_KEY);
|
|
if (!raw) return;
|
|
const hiddenFields = JSON.parse(raw);
|
|
if (Array.isArray(hiddenFields)) {
|
|
hiddenFields.forEach((field) => {
|
|
$table.bootstrapTable('hideColumn', field);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function savePreferencesFromUrl() {
|
|
if (typeof savePreferencesUrl === 'undefined') return;
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const filters = {};
|
|
const filterParams = ['thematic', 'category', 'symptom', 'status', 'type', 'mine', 'q', 'search_mode'];
|
|
filterParams.forEach((param) => {
|
|
const values = urlParams.getAll(param);
|
|
if (values.length > 1) {
|
|
filters[param] = values;
|
|
} else if (values.length === 1 && values[0] !== '') {
|
|
filters[param] = values[0];
|
|
}
|
|
});
|
|
|
|
const $table = $('#observations-table');
|
|
let sort = {};
|
|
if ($table.length && $table.data('bootstrap.table')) {
|
|
const options = $table.bootstrapTable('getOptions');
|
|
if (options.sortName) {
|
|
sort = { sortName: options.sortName, sortOrder: options.sortOrder || 'asc' };
|
|
}
|
|
}
|
|
|
|
fetch(savePreferencesUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
|
|
body: JSON.stringify({ filters, sort })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
async function savePreferencesFromForm(urlParams) {
|
|
if (typeof savePreferencesUrl === 'undefined') return;
|
|
|
|
const filters = {};
|
|
const filterParams = ['thematic', 'category', 'symptom', 'status', 'type', 'mine', 'q', 'search_mode'];
|
|
filterParams.forEach((param) => {
|
|
const values = urlParams.getAll(param);
|
|
if (values.length > 1) {
|
|
filters[param] = values;
|
|
} else if (values.length === 1 && values[0] !== '') {
|
|
filters[param] = values[0];
|
|
}
|
|
});
|
|
|
|
const $table = $('#observations-table');
|
|
let sort = {};
|
|
if ($table.length && $table.data('bootstrap.table')) {
|
|
const options = $table.bootstrapTable('getOptions');
|
|
if (options.sortName) {
|
|
sort = { sortName: options.sortName, sortOrder: options.sortOrder || 'asc' };
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fetch(savePreferencesUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
|
|
body: JSON.stringify({ filters, sort }),
|
|
keepalive: true
|
|
});
|
|
} catch (e) {
|
|
// Silently fail
|
|
}
|
|
}
|
|
|
|
// Save preferences on page load if filters are already present in URL
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (window.location.search) {
|
|
// Debounce to allow table to initialize first
|
|
setTimeout(savePreferencesFromUrl, 1000);
|
|
}
|
|
});
|