feat(assets): add click toggle and polygon containment selection tools on unified map
This commit is contained in:
parent
b077ed1a74
commit
9bc41dea80
2 changed files with 640 additions and 26 deletions
|
|
@ -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 = `<i class="bi ${isNowSel ? 'bi-check-square-fill' : 'bi-square'}"></i>`;
|
||||
});
|
||||
});
|
||||
|
||||
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 = `<i class="bi ${isNowSel ? 'bi-check-square-fill' : 'bi-square'}"></i>`;
|
||||
});
|
||||
});
|
||||
|
||||
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 = `<i class="bi ${isNowSel ? 'bi-check-square-fill' : 'bi-square'}"></i>`;
|
||||
});
|
||||
});
|
||||
|
||||
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 `
|
||||
<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;">
|
||||
|
|
@ -2323,15 +2372,24 @@ function showAssetClickPopup(clickEvent) {
|
|||
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}${extra}</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 style="display:flex; gap:4px; align-items:center;">
|
||||
<button type="button"
|
||||
class="btn btn-sm ${isASelected ? 'btn-primary' : 'btn-light'} asset-popup-select-btn"
|
||||
data-asset-id="${escapeHtml(a.id)}"
|
||||
data-thematic="${escapeHtml(a.thematic)}"
|
||||
data-asset-type="${escapeHtml(a.asset_type)}"
|
||||
title="${escapeHtml(isASelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||
<i class="bi ${isASelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||
</button>
|
||||
<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'))}">
|
||||
<i class="bi bi-table"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
|
@ -2350,6 +2408,8 @@ function showAssetClickPopup(clickEvent) {
|
|||
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
||||
: `<span>${code}</span>`;
|
||||
|
||||
const isLSelected = selectedIds.has(l.id) || selectedIds.has(String(l.id)) || (typeof l.id === 'string' && !isNaN(l.id) && selectedIds.has(Number(l.id)));
|
||||
|
||||
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;">
|
||||
|
|
@ -2357,14 +2417,22 @@ function showAssetClickPopup(clickEvent) {
|
|||
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${usage}${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 style="display:flex; gap:4px; align-items:center;">
|
||||
<button type="button"
|
||||
class="btn btn-sm ${isLSelected ? 'btn-primary' : 'btn-light'} location-popup-select-btn"
|
||||
data-location-id="${escapeHtml(l.id)}"
|
||||
data-thematic="${escapeHtml(l.thematic)}"
|
||||
title="${escapeHtml(isLSelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||
<i class="bi ${isLSelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||
</button>
|
||||
<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'))}">
|
||||
<i class="bi bi-table"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
|
@ -2407,6 +2475,8 @@ function showAssetClickPopup(clickEvent) {
|
|||
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
|
||||
: `<span>${code}</span>`;
|
||||
|
||||
const isZSelected = selectedIds.has(z.id) || selectedIds.has(String(z.id)) || (typeof z.id === 'string' && !isNaN(z.id) && selectedIds.has(Number(z.id)));
|
||||
|
||||
return `
|
||||
<div class="zone-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;">
|
||||
|
|
@ -2414,14 +2484,22 @@ function showAssetClickPopup(clickEvent) {
|
|||
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${cat}${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 zone-popup-focus-btn"
|
||||
data-zone-id="${escapeHtml(z.id)}"
|
||||
data-thematic="${escapeHtml(z.thematic)}"
|
||||
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
||||
style="transform:translateX(-5px);">
|
||||
<i class="bi bi-table"></i>
|
||||
</button>
|
||||
<div style="display:flex; gap:4px; align-items:center;">
|
||||
<button type="button"
|
||||
class="btn btn-sm ${isZSelected ? 'btn-primary' : 'btn-light'} zone-popup-select-btn"
|
||||
data-zone-id="${escapeHtml(z.id)}"
|
||||
data-thematic="${escapeHtml(z.thematic)}"
|
||||
title="${escapeHtml(isZSelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||
<i class="bi ${isZSelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light zone-popup-focus-btn"
|
||||
data-zone-id="${escapeHtml(z.id)}"
|
||||
data-thematic="${escapeHtml(z.thematic)}"
|
||||
title="${escapeHtml(gettext('Voir dans le tableau'))}">
|
||||
<i class="bi bi-table"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z"/>
|
||||
</svg>
|
||||
`;
|
||||
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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
|
||||
<polygon points="12 2 22 8.5 17 21 7 21 2 8.5 12 2" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="2" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="8.5" r="2" fill="currentColor"/>
|
||||
<circle cx="17" cy="21" r="2" fill="currentColor"/>
|
||||
<circle cx="7" cy="21" r="2" fill="currentColor"/>
|
||||
<circle cx="2" cy="8.5" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
`;
|
||||
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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm12 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12z"/>
|
||||
</svg>
|
||||
`;
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,46 @@
|
|||
<style>
|
||||
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
||||
#unified-empty-state { display: none; color: #6c757d; }
|
||||
.unified-selection-ctrl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.unified-selection-ctrl button {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: #ffffff;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.unified-selection-ctrl button:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
.unified-selection-ctrl button.active {
|
||||
background-color: #0d6efd !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.unified-selection-ctrl button.active svg {
|
||||
fill: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
.unified-selection-ctrl button.active polygon {
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
.unified-selection-ctrl button.active circle {
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
#map-selection-hint {
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
/* ─── Modern Airbnb-style Filter Modal ──────────────────── */
|
||||
#filterModal .modal-dialog {
|
||||
max-width: 820px;
|
||||
|
|
@ -1442,6 +1482,9 @@
|
|||
<div id="assets-hint" class="small fst-italic position-absolute start-50 translate-middle-x bg-white px-3 py-1 rounded-pill shadow-sm border" style="top: 10px; z-index: 5; visibility: hidden; color: #0d6efd; white-space: nowrap; pointer-events: none;">
|
||||
<i class="bi bi-info-circle me-1"></i>{% translate "Choisissez une catégorie ou zoomez sur la carte pour voir les assets." %}
|
||||
</div>
|
||||
<div id="map-selection-hint" class="small fw-semibold position-absolute start-50 translate-middle-x bg-primary text-white px-3 py-1 rounded-pill shadow" style="top: 10px; z-index: 6; display: none; white-space: nowrap; pointer-events: none;">
|
||||
<i class="bi bi-cursor-fill me-1" id="map-selection-hint-icon"></i><span id="map-selection-hint-text"></span>
|
||||
</div>
|
||||
<div id="totalsOverlay" class="totals-overlay">
|
||||
<div class="totals-toolbar">
|
||||
<strong>{% translate "Totaux des assets" %}</strong>
|
||||
|
|
@ -1640,5 +1683,5 @@
|
|||
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
||||
<script src="{% static 'assets/asset_symbology.js' %}?v=20261012_circle_select_v5"></script>
|
||||
<script src="{% static 'assets/unified_map_layers.js' %}?v=20261012_circle_select_v5"></script>
|
||||
<script src="{% static 'assets/unified_index.js' %}?v=20261012_circle_select_v5"></script>
|
||||
<script src="{% static 'assets/unified_index.js' %}?v=20261012_map_selection_v1"></script>
|
||||
{% endblock script %}
|
||||
Loading…
Reference in a new issue