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 shouldFitSearch = false;
|
||||||
var searchIsGlobal = false;
|
var searchIsGlobal = false;
|
||||||
var selectedIds = new Set(); // ids of checked rows
|
var selectedIds = new Set(); // ids of checked rows
|
||||||
|
var mapSelectionMode = null; // 'click' | 'polygon' | null
|
||||||
|
var mapDrawInstance = null;
|
||||||
var loadingCount = 0;
|
var loadingCount = 0;
|
||||||
|
|
||||||
var categoryLabelByThematicAndId = {};
|
var categoryLabelByThematicAndId = {};
|
||||||
|
|
@ -2236,6 +2238,43 @@ function collectClickedLocations(clickEvent) {
|
||||||
|
|
||||||
function bindAssetPopupActions(popupNode) {
|
function bindAssetPopupActions(popupNode) {
|
||||||
if (!popupNode) return;
|
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 => {
|
popupNode.querySelectorAll('.asset-popup-focus-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', function (e) {
|
btn.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -2292,6 +2331,14 @@ function showAssetClickPopup(clickEvent) {
|
||||||
const managementZones = collectClickedManagementZones(clickEvent);
|
const managementZones = collectClickedManagementZones(clickEvent);
|
||||||
if (!assets.length && !locations.length && !managementZones.length) return;
|
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 maxItems = 8;
|
||||||
const visibleAssets = assets.slice(0, maxItems);
|
const visibleAssets = assets.slice(0, maxItems);
|
||||||
const hiddenCount = Math.max(0, assets.length - visibleAssets.length);
|
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 `
|
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 class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
||||||
<div style="min-width:0;">
|
<div style="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>
|
<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>` : ''}
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div style="display:flex; gap:4px; align-items:center;">
|
||||||
class="btn btn-sm btn-light asset-popup-focus-btn"
|
<button type="button"
|
||||||
data-asset-id="${escapeHtml(a.id)}"
|
class="btn btn-sm ${isASelected ? 'btn-primary' : 'btn-light'} asset-popup-select-btn"
|
||||||
data-thematic="${escapeHtml(a.thematic)}"
|
data-asset-id="${escapeHtml(a.id)}"
|
||||||
data-asset-type="${escapeHtml(a.asset_type)}"
|
data-thematic="${escapeHtml(a.thematic)}"
|
||||||
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
data-asset-type="${escapeHtml(a.asset_type)}"
|
||||||
style="transform:translateX(-5px);">
|
title="${escapeHtml(isASelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||||
<i class="bi bi-table"></i>
|
<i class="bi ${isASelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -2350,6 +2408,8 @@ function showAssetClickPopup(clickEvent) {
|
||||||
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
||||||
: `<span>${code}</span>`;
|
: `<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 `
|
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 class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
||||||
<div style="min-width:0;">
|
<div style="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>
|
<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>` : ''}
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div style="display:flex; gap:4px; align-items:center;">
|
||||||
class="btn btn-sm btn-light location-popup-focus-btn"
|
<button type="button"
|
||||||
data-location-id="${escapeHtml(l.id)}"
|
class="btn btn-sm ${isLSelected ? 'btn-primary' : 'btn-light'} location-popup-select-btn"
|
||||||
data-thematic="${escapeHtml(l.thematic)}"
|
data-location-id="${escapeHtml(l.id)}"
|
||||||
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
data-thematic="${escapeHtml(l.thematic)}"
|
||||||
style="transform:translateX(-5px);">
|
title="${escapeHtml(isLSelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||||
<i class="bi bi-table"></i>
|
<i class="bi ${isLSelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -2407,6 +2475,8 @@ function showAssetClickPopup(clickEvent) {
|
||||||
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
|
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
|
||||||
: `<span>${code}</span>`;
|
: `<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 `
|
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 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;">
|
<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>
|
<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>` : ''}
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div style="display:flex; gap:4px; align-items:center;">
|
||||||
class="btn btn-sm btn-light zone-popup-focus-btn"
|
<button type="button"
|
||||||
data-zone-id="${escapeHtml(z.id)}"
|
class="btn btn-sm ${isZSelected ? 'btn-primary' : 'btn-light'} zone-popup-select-btn"
|
||||||
data-thematic="${escapeHtml(z.thematic)}"
|
data-zone-id="${escapeHtml(z.id)}"
|
||||||
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
data-thematic="${escapeHtml(z.thematic)}"
|
||||||
style="transform:translateX(-5px);">
|
title="${escapeHtml(isZSelected ? gettext('Désélectionner') : gettext('Sélectionner'))}">
|
||||||
<i class="bi bi-table"></i>
|
<i class="bi ${isZSelected ? 'bi-check-square-fill' : 'bi-square'}"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -3169,6 +3247,9 @@ maploader.addEventListener('mapLoaded', () => {
|
||||||
map.addControl(new AddressSearchControl({ autocompleteUrl: AUTOCOMPLETE_STREET_URL }), 'top-left');
|
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)
|
// Restore saved map position (set by applyInitialPreferences)
|
||||||
if (window._restoredMapCenter && window._restoredMapZoom) {
|
if (window._restoredMapCenter && window._restoredMapZoom) {
|
||||||
map.jumpTo({ center: window._restoredMapCenter, zoom: 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() {
|
function updateSelectionStatus() {
|
||||||
const el = document.getElementById('unified-selection-status');
|
const el = document.getElementById('unified-selection-status');
|
||||||
if (el) el.textContent = `${selectedIds.size} ${gettext('sélectionné(s)')}`;
|
if (el) el.textContent = `${selectedIds.size} ${gettext('sélectionné(s)')}`;
|
||||||
|
|
@ -3392,6 +3962,7 @@ function updateSelectionStatus() {
|
||||||
const hiddenLocationsInput = document.getElementById('preventive-selected-locations-ids');
|
const hiddenLocationsInput = document.getElementById('preventive-selected-locations-ids');
|
||||||
if (hiddenLocationsInput) hiddenLocationsInput.value = Array.from(selectedIds).join(',');
|
if (hiddenLocationsInput) hiddenLocationsInput.value = Array.from(selectedIds).join(',');
|
||||||
syncSelectAllCheckbox();
|
syncSelectAllCheckbox();
|
||||||
|
updateClearSelectionButtonVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreventiveSelectedAssetsPayload() {
|
function buildPreventiveSelectedAssetsPayload() {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,46 @@
|
||||||
<style>
|
<style>
|
||||||
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
||||||
#unified-empty-state { display: none; color: #6c757d; }
|
#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 ──────────────────── */
|
/* ─── Modern Airbnb-style Filter Modal ──────────────────── */
|
||||||
#filterModal .modal-dialog {
|
#filterModal .modal-dialog {
|
||||||
max-width: 820px;
|
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;">
|
<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." %}
|
<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>
|
||||||
|
<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 id="totalsOverlay" class="totals-overlay">
|
||||||
<div class="totals-toolbar">
|
<div class="totals-toolbar">
|
||||||
<strong>{% translate "Totaux des assets" %}</strong>
|
<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/sign_panels_shared.js' %}?v=20260703c"></script>
|
||||||
<script src="{% static 'assets/asset_symbology.js' %}?v=20261012_circle_select_v5"></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_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 %}
|
{% endblock script %}
|
||||||
Loading…
Reference in a new issue