diff --git a/loko/assets/static/assets/unified_index.js b/loko/assets/static/assets/unified_index.js
index dbeebad..b17dfb2 100644
--- a/loko/assets/static/assets/unified_index.js
+++ b/loko/assets/static/assets/unified_index.js
@@ -33,6 +33,8 @@ var searchMode = 'and';
var shouldFitSearch = false;
var searchIsGlobal = false;
var selectedIds = new Set(); // ids of checked rows
+var mapSelectionMode = null; // 'click' | 'polygon' | null
+var mapDrawInstance = null;
var loadingCount = 0;
var categoryLabelByThematicAndId = {};
@@ -2236,6 +2238,43 @@ function collectClickedLocations(clickEvent) {
function bindAssetPopupActions(popupNode) {
if (!popupNode) return;
+
+ popupNode.querySelectorAll('.asset-popup-select-btn').forEach(btn => {
+ btn.addEventListener('click', function (e) {
+ e.preventDefault();
+ const id = this.dataset.assetId;
+ toggleAssetSelection(id, { id: id, is_location: false, is_management_zone: false });
+ const isNowSel = selectedIds.has(id) || selectedIds.has(String(id)) || selectedIds.has(Number(id));
+ this.className = `btn btn-sm ${isNowSel ? 'btn-primary' : 'btn-light'} asset-popup-select-btn`;
+ this.title = isNowSel ? gettext('Désélectionner') : gettext('Sélectionner');
+ this.innerHTML = ``;
+ });
+ });
+
+ popupNode.querySelectorAll('.location-popup-select-btn').forEach(btn => {
+ btn.addEventListener('click', function (e) {
+ e.preventDefault();
+ const id = this.dataset.locationId;
+ toggleAssetSelection(id, { id: id, is_location: true, is_management_zone: false });
+ const isNowSel = selectedIds.has(id) || selectedIds.has(String(id)) || selectedIds.has(Number(id));
+ this.className = `btn btn-sm ${isNowSel ? 'btn-primary' : 'btn-light'} location-popup-select-btn`;
+ this.title = isNowSel ? gettext('Désélectionner') : gettext('Sélectionner');
+ this.innerHTML = ``;
+ });
+ });
+
+ popupNode.querySelectorAll('.zone-popup-select-btn').forEach(btn => {
+ btn.addEventListener('click', function (e) {
+ e.preventDefault();
+ const id = this.dataset.zoneId;
+ toggleAssetSelection(id, { id: id, is_location: false, is_management_zone: true });
+ const isNowSel = selectedIds.has(id) || selectedIds.has(String(id)) || selectedIds.has(Number(id));
+ this.className = `btn btn-sm ${isNowSel ? 'btn-primary' : 'btn-light'} zone-popup-select-btn`;
+ this.title = isNowSel ? gettext('Désélectionner') : gettext('Sélectionner');
+ this.innerHTML = ``;
+ });
+ });
+
popupNode.querySelectorAll('.asset-popup-focus-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
@@ -2292,6 +2331,14 @@ function showAssetClickPopup(clickEvent) {
const managementZones = collectClickedManagementZones(clickEvent);
if (!assets.length && !locations.length && !managementZones.length) return;
+ if (mapSelectionMode === 'click') {
+ const target = assets[0] || locations[0] || managementZones[0];
+ if (target && target.id != null) {
+ toggleAssetSelection(target.id, target);
+ }
+ return;
+ }
+
const maxItems = 8;
const visibleAssets = assets.slice(0, maxItems);
const hiddenCount = Math.max(0, assets.length - visibleAssets.length);
@@ -2316,6 +2363,8 @@ function showAssetClickPopup(clickEvent) {
});
}
+ const isASelected = selectedIds.has(a.id) || selectedIds.has(String(a.id)) || (typeof a.id === 'string' && !isNaN(a.id) && selectedIds.has(Number(a.id)));
+
return `
`;
}).join('');
@@ -2350,6 +2408,8 @@ function showAssetClickPopup(clickEvent) {
? `${code}`
: `${code}`;
+ const isLSelected = selectedIds.has(l.id) || selectedIds.has(String(l.id)) || (typeof l.id === 'string' && !isNaN(l.id) && selectedIds.has(Number(l.id)));
+
return `
`;
}).join('');
@@ -2407,6 +2475,8 @@ function showAssetClickPopup(clickEvent) {
? `${code}`
: `${code}`;
+ const isZSelected = selectedIds.has(z.id) || selectedIds.has(String(z.id)) || (typeof z.id === 'string' && !isNaN(z.id) && selectedIds.has(Number(z.id)));
+
return `
`;
}).join('');
@@ -3169,6 +3247,9 @@ maploader.addEventListener('mapLoaded', () => {
map.addControl(new AddressSearchControl({ autocompleteUrl: AUTOCOMPLETE_STREET_URL }), 'top-left');
}
+ // Map selection tools (click toggle & polygon draw selection)
+ initMapSelectionControl();
+
// Restore saved map position (set by applyInitialPreferences)
if (window._restoredMapCenter && window._restoredMapZoom) {
map.jumpTo({ center: window._restoredMapCenter, zoom: window._restoredMapZoom });
@@ -3383,6 +3464,495 @@ function syncSelectAllCheckbox() {
});
}
+// ─── Map Selection Tools (Click & Polygon) ────────────────────────────────────
+
+/**
+ * Toggles selection for a single asset, location, or management zone.
+ *
+ * @param {string|number} id
+ * @param {object} [target]
+ */
+function toggleAssetSelection(id, target) {
+ if (id == null) return;
+ const isSelected = selectedIds.has(id) || selectedIds.has(String(id)) || (typeof id === 'string' && !isNaN(id) && selectedIds.has(Number(id)));
+ const nextState = !isSelected;
+
+ if (nextState) {
+ selectedIds.add(id);
+ } else {
+ selectedIds.delete(id);
+ selectedIds.delete(String(id));
+ if (typeof id === 'string' && !isNaN(id)) {
+ selectedIds.delete(Number(id));
+ }
+ }
+
+ setMapFeatureSelectionState(target || id, nextState);
+
+ // Update row in table data
+ const allRows = getAllTableRows();
+ const row = allRows.find(r => String(r.id) === String(id));
+ if (row) {
+ row.state = nextState;
+ row.check = nextState;
+ }
+
+ // Update visible checkboxes in Bootstrap Table
+ $('#unified-table input[name="btSelectItem"]').each(function () {
+ const idx = $(this).data('index');
+ const r = $('#unified-table').bootstrapTable('getData')[idx];
+ if (r && String(r.id) === String(id)) {
+ $(this).prop('checked', nextState);
+ $(this).closest('tr').toggleClass('selected', nextState);
+ }
+ });
+
+ updateSelectionStatus();
+}
+
+/**
+ * Geometric helper: tests whether a 2D point [x, y] lies inside a polygon ring
+ * using the ray-casting algorithm (Jordan curve theorem).
+ */
+function pointInRing(pt, ring) {
+ const x = pt[0], y = pt[1];
+ let inside = false;
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ const xi = ring[i][0], yi = ring[i][1];
+ const xj = ring[j][0], yj = ring[j][1];
+ const intersect = ((yi > y) !== (yj > y)) &&
+ (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+
+/**
+ * Computes bounding box [minX, minY, maxX, maxY] for a ring of points.
+ */
+function getRingBbox(ring) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let i = 0; i < ring.length; i++) {
+ const x = ring[i][0], y = ring[i][1];
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+ return [minX, minY, maxX, maxY];
+}
+
+/**
+ * Returns true if points a, b, c are in counter-clockwise order.
+ */
+function ccw(a, b, c) {
+ return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0]);
+}
+
+/**
+ * Tests whether segment (p1, p2) properly crosses segment (q1, q2).
+ */
+function segmentsCross(p1, p2, q1, q2) {
+ if ((p1[0] === q1[0] && p1[1] === q1[1]) ||
+ (p1[0] === q2[0] && p1[1] === q2[1]) ||
+ (p2[0] === q1[0] && p2[1] === q1[1]) ||
+ (p2[0] === q2[0] && p2[1] === q2[1])) {
+ return false;
+ }
+ return (ccw(p1, q1, q2) !== ccw(p2, q1, q2)) && (ccw(p1, p2, q1) !== ccw(p1, p2, q2));
+}
+
+/**
+ * Checks if a GeoJSON feature is totally included within a polygon ring.
+ * - Point: point must be inside ring.
+ * - MultiPoint: all points must be inside ring.
+ * - LineString: all vertices inside ring AND no segment crosses ring edges.
+ * - MultiLineString: all linestrings totally inside.
+ * - Polygon: all vertices of exterior ring inside AND no segment crosses ring edges.
+ * - MultiPolygon: all polygons totally inside.
+ */
+function isFeatureTotallyInsideRing(feature, ring, ringBbox) {
+ if (!feature || !feature.geometry) return false;
+ const geom = feature.geometry;
+ if (!geom.coordinates) return false;
+
+ if (!ringBbox) {
+ ringBbox = getRingBbox(ring);
+ }
+ const [minX, minY, maxX, maxY] = ringBbox;
+
+ switch (geom.type) {
+ case 'Point': {
+ const pt = geom.coordinates;
+ if (pt[0] < minX || pt[0] > maxX || pt[1] < minY || pt[1] > maxY) return false;
+ return pointInRing(pt, ring);
+ }
+
+ case 'MultiPoint': {
+ const pts = geom.coordinates;
+ if (!pts.length) return false;
+ for (let i = 0; i < pts.length; i++) {
+ const pt = pts[i];
+ if (pt[0] < minX || pt[0] > maxX || pt[1] < minY || pt[1] > maxY) return false;
+ if (!pointInRing(pt, ring)) return false;
+ }
+ return true;
+ }
+
+ case 'LineString': {
+ const pts = geom.coordinates;
+ if (!pts.length) return false;
+ for (let i = 0; i < pts.length; i++) {
+ const pt = pts[i];
+ if (pt[0] < minX || pt[0] > maxX || pt[1] < minY || pt[1] > maxY) return false;
+ if (!pointInRing(pt, ring)) return false;
+ }
+ for (let s = 0; s < pts.length - 1; s++) {
+ const p1 = pts[s], p2 = pts[s + 1];
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ if (segmentsCross(p1, p2, ring[i], ring[j])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ case 'MultiLineString': {
+ const lines = geom.coordinates;
+ if (!lines.length) return false;
+ for (let l = 0; l < lines.length; l++) {
+ const dummyFeat = { geometry: { type: 'LineString', coordinates: lines[l] } };
+ if (!isFeatureTotallyInsideRing(dummyFeat, ring, ringBbox)) return false;
+ }
+ return true;
+ }
+
+ case 'Polygon': {
+ const extRing = geom.coordinates && geom.coordinates[0];
+ if (!extRing || !extRing.length) return false;
+ for (let i = 0; i < extRing.length; i++) {
+ const pt = extRing[i];
+ if (pt[0] < minX || pt[0] > maxX || pt[1] < minY || pt[1] > maxY) return false;
+ if (!pointInRing(pt, ring)) return false;
+ }
+ for (let s = 0, t = extRing.length - 1; s < extRing.length; t = s++) {
+ const p1 = extRing[t], p2 = extRing[s];
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ if (segmentsCross(p1, p2, ring[i], ring[j])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ case 'MultiPolygon': {
+ const polys = geom.coordinates;
+ if (!polys.length) return false;
+ for (let p = 0; p < polys.length; p++) {
+ const dummyFeat = { geometry: { type: 'Polygon', coordinates: polys[p] } };
+ if (!isFeatureTotallyInsideRing(dummyFeat, ring, ringBbox)) return false;
+ }
+ return true;
+ }
+
+ default:
+ return false;
+ }
+}
+
+/**
+ * Finds all features in filteredFeatures totally enclosed within polygonGeom and selects them.
+ *
+ * @param {object} polygonGeom - GeoJSON Polygon or MultiPolygon geometry
+ * @returns {number} Count of features enclosed in polygon
+ */
+function selectAssetsInsidePolygon(polygonGeom) {
+ if (!polygonGeom || !polygonGeom.coordinates) return 0;
+ const rings = polygonGeom.type === 'Polygon'
+ ? [polygonGeom.coordinates[0]]
+ : (polygonGeom.type === 'MultiPolygon' ? polygonGeom.coordinates.map(p => p[0]) : []);
+
+ if (!rings.length) return 0;
+
+ const ringBboxes = rings.map(r => getRingBbox(r));
+ let totalContainedCount = 0;
+
+ filteredFeatures.forEach(f => {
+ if (!hasValidCoordinates(f)) return;
+ const fid = f.id != null ? f.id : (f.properties && f.properties.id);
+ if (fid == null) return;
+
+ const isContained = rings.some((ring, idx) => isFeatureTotallyInsideRing(f, ring, ringBboxes[idx]));
+ if (isContained) {
+ totalContainedCount++;
+ selectedIds.add(fid);
+ setMapFeatureSelectionState(f, true);
+ }
+ });
+
+ if (totalContainedCount > 0) {
+ const allRows = getAllTableRows();
+ allRows.forEach(r => {
+ if (selectedIds.has(r.id) || selectedIds.has(String(r.id)) || (typeof r.id === 'string' && !isNaN(r.id) && selectedIds.has(Number(r.id)))) {
+ r.state = true;
+ r.check = true;
+ }
+ });
+
+ $('#unified-table input[name="btSelectItem"]').each(function () {
+ const idx = $(this).data('index');
+ const row = $('#unified-table').bootstrapTable('getData')[idx];
+ if (row && (selectedIds.has(row.id) || selectedIds.has(String(row.id)) || (typeof row.id === 'string' && !isNaN(row.id) && selectedIds.has(Number(row.id))))) {
+ $(this).prop('checked', true);
+ $(this).closest('tr').addClass('selected');
+ }
+ });
+
+ updateSelectionStatus();
+ }
+
+ return totalContainedCount;
+}
+
+function handlePolygonSelectionCreated(e) {
+ if (!e || !e.features || !e.features.length) return;
+ const geom = e.features[0].geometry;
+ const count = selectAssetsInsidePolygon(geom);
+
+ if (mapDrawInstance) {
+ mapDrawInstance.deleteAll();
+ }
+
+ setMapSelectionMode(null);
+
+ if (count > 0) {
+ showToast(`${count} ${gettext('asset(s) sélectionné(s) dans le polygone')}`, 'primary');
+ } else {
+ showToast(gettext('Aucun asset trouvé totalement inclus dans le polygone'), 'info');
+ }
+}
+
+function handleDrawModeChange(e) {
+ if (mapSelectionMode === 'polygon' && e && e.mode !== 'draw_polygon') {
+ setMapSelectionMode(null);
+ }
+}
+
+function setMapSelectionMode(mode) {
+ mapSelectionMode = mode;
+
+ const clickBtn = document.getElementById('unified-map-click-sel-btn');
+ const polyBtn = document.getElementById('unified-map-poly-sel-btn');
+ const hintEl = document.getElementById('map-selection-hint');
+ const hintText = document.getElementById('map-selection-hint-text');
+ const hintIcon = document.getElementById('map-selection-hint-icon');
+
+ if (clickBtn) clickBtn.classList.toggle('active', mode === 'click');
+ if (polyBtn) polyBtn.classList.toggle('active', mode === 'polygon');
+
+ if (mode === 'click') {
+ if (mapDrawInstance) {
+ mapDrawInstance.deleteAll();
+ mapDrawInstance.changeMode('simple_select');
+ }
+ if (hintEl && hintText) {
+ hintText.textContent = gettext('Cliquez sur un asset pour le sélectionner ou le désélectionner');
+ if (hintIcon) hintIcon.className = 'bi bi-cursor-fill me-1';
+ hintEl.style.display = 'block';
+ }
+ } else if (mode === 'polygon') {
+ if (hintEl && hintText) {
+ hintText.textContent = gettext('Cliquez pour dessiner un polygone. Double-cliquez pour terminer la sélection.');
+ if (hintIcon) hintIcon.className = 'bi bi-bounding-box me-1';
+ hintEl.style.display = 'block';
+ }
+ if (mapDrawInstance) {
+ mapDrawInstance.deleteAll();
+ mapDrawInstance.changeMode('draw_polygon');
+ }
+ } else {
+ if (mapDrawInstance) {
+ mapDrawInstance.deleteAll();
+ mapDrawInstance.changeMode('simple_select');
+ }
+ if (hintEl) {
+ hintEl.style.display = 'none';
+ }
+ }
+}
+
+function updateClearSelectionButtonVisibility() {
+ const clearBtn = document.getElementById('unified-map-clear-sel-btn');
+ if (!clearBtn) return;
+ if (selectedIds.size > 0) {
+ clearBtn.style.display = 'flex';
+ clearBtn.title = `${gettext('Désélectionner tout')} (${selectedIds.size})`;
+ } else {
+ clearBtn.style.display = 'none';
+ }
+}
+
+function initMapDraw() {
+ if (typeof MapboxDraw === 'undefined') return null;
+
+ MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
+ MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
+ MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
+ MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
+ MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
+
+ const drawStyles = [
+ {
+ id: 'gl-draw-polygon-fill',
+ type: 'fill',
+ filter: ['all', ['==', '$type', 'Polygon']],
+ paint: {
+ 'fill-color': '#0d6efd',
+ 'fill-opacity': 0.15
+ }
+ },
+ {
+ id: 'gl-draw-lines',
+ type: 'line',
+ filter: ['any', ['==', '$type', 'LineString'], ['==', '$type', 'Polygon']],
+ layout: {
+ 'line-cap': 'round',
+ 'line-join': 'round'
+ },
+ paint: {
+ 'line-color': '#0d6efd',
+ 'line-dasharray': [2, 2],
+ 'line-width': 2.5
+ }
+ },
+ {
+ id: 'gl-draw-vertex-outer',
+ type: 'circle',
+ filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'vertex']],
+ paint: {
+ 'circle-radius': 6,
+ 'circle-color': '#ffffff',
+ 'circle-stroke-width': 2,
+ 'circle-stroke-color': '#0d6efd'
+ }
+ },
+ {
+ id: 'gl-draw-midpoint',
+ type: 'circle',
+ filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
+ paint: {
+ 'circle-radius': 4,
+ 'circle-color': '#0d6efd'
+ }
+ }
+ ];
+
+ mapDrawInstance = new MapboxDraw({
+ displayControlsDefault: false,
+ controls: {},
+ styles: drawStyles
+ });
+
+ map.addControl(mapDrawInstance);
+
+ map.on('draw.create', handlePolygonSelectionCreated);
+ map.on('draw.modechange', handleDrawModeChange);
+
+ return mapDrawInstance;
+}
+
+function initMapSelectionControl() {
+ initMapDraw();
+
+ const selectionControl = {
+ onAdd(m) {
+ this._map = m;
+ const container = document.createElement('div');
+ container.className = 'maplibregl-ctrl maplibregl-ctrl-group unified-selection-ctrl';
+
+ // Button 1: Click selection mode
+ const clickBtn = document.createElement('button');
+ clickBtn.type = 'button';
+ clickBtn.id = 'unified-map-click-sel-btn';
+ clickBtn.title = gettext('Sélection par clic (sélectionner ou désélectionner au clic)');
+ clickBtn.innerHTML = `
+
+ `;
+ clickBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (mapSelectionMode === 'click') {
+ setMapSelectionMode(null);
+ } else {
+ setMapSelectionMode('click');
+ }
+ });
+ container.appendChild(clickBtn);
+
+ // Button 2: Polygon selection mode
+ const polyBtn = document.createElement('button');
+ polyBtn.type = 'button';
+ polyBtn.id = 'unified-map-poly-sel-btn';
+ polyBtn.title = gettext('Sélection par polygone (dessiner une zone pour sélectionner les assets inclus)');
+ polyBtn.innerHTML = `
+
+ `;
+ polyBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (mapSelectionMode === 'polygon') {
+ setMapSelectionMode(null);
+ } else {
+ setMapSelectionMode('polygon');
+ }
+ });
+ container.appendChild(polyBtn);
+
+ // Button 3: Clear all selection
+ const clearBtn = document.createElement('button');
+ clearBtn.type = 'button';
+ clearBtn.id = 'unified-map-clear-sel-btn';
+ clearBtn.title = gettext('Désélectionner tout');
+ clearBtn.style.display = selectedIds.size > 0 ? 'flex' : 'none';
+ clearBtn.innerHTML = `
+
+ `;
+ clearBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ deselectAllTableRows();
+ });
+ container.appendChild(clearBtn);
+
+ this._container = container;
+ return container;
+ },
+ onRemove() {
+ this._container?.parentNode?.removeChild(this._container);
+ this._map = undefined;
+ }
+ };
+
+ map.addControl(selectionControl, 'top-left');
+
+ window.addEventListener('keydown', (e) => {
+ if ((e.key === 'Escape' || e.keyCode === 27) && mapSelectionMode) {
+ setMapSelectionMode(null);
+ }
+ });
+}
+
function updateSelectionStatus() {
const el = document.getElementById('unified-selection-status');
if (el) el.textContent = `${selectedIds.size} ${gettext('sélectionné(s)')}`;
@@ -3392,6 +3962,7 @@ function updateSelectionStatus() {
const hiddenLocationsInput = document.getElementById('preventive-selected-locations-ids');
if (hiddenLocationsInput) hiddenLocationsInput.value = Array.from(selectedIds).join(',');
syncSelectAllCheckbox();
+ updateClearSelectionButtonVisibility();
}
function buildPreventiveSelectedAssetsPayload() {
diff --git a/loko/assets/templates/assets/unified_index.html b/loko/assets/templates/assets/unified_index.html
index 4f88f0a..29062c6 100644
--- a/loko/assets/templates/assets/unified_index.html
+++ b/loko/assets/templates/assets/unified_index.html
@@ -9,6 +9,46 @@