From 9bc41dea803aa07aff009c79d486ea19b72cab8c Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 12 Oct 2026 19:05:16 +0200 Subject: [PATCH] feat(assets): add click toggle and polygon containment selection tools on unified map --- loko/assets/static/assets/unified_index.js | 621 +++++++++++++++++- .../templates/assets/unified_index.html | 45 +- 2 files changed, 640 insertions(+), 26 deletions(-) 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 `
@@ -2323,15 +2372,24 @@ function showAssetClickPopup(clickEvent) {
${type}${thematic ? ' - ' + thematic : ''}${extra}
${name ? `
${name}
` : ''}
- +
+ + +
`; }).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 `
@@ -2357,14 +2417,22 @@ function showAssetClickPopup(clickEvent) {
${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}
${name ? `
${name}
` : ''}
- +
+ + +
`; }).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 `
@@ -2414,14 +2484,22 @@ function showAssetClickPopup(clickEvent) {
${dotHtml}${type}${cat}${thematic ? ' - ' + thematic : ''}
${name ? `
${name}
` : ''}
- +
+ + +
`; }).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 @@