diff --git a/loko/assets/static/assets/unified_index.js b/loko/assets/static/assets/unified_index.js
index b17dfb2..947902f 100644
--- a/loko/assets/static/assets/unified_index.js
+++ b/loko/assets/static/assets/unified_index.js
@@ -2134,20 +2134,28 @@ function collectClickedAssets(clickEvent) {
const layers = [
'assets',
'assets-point-selection',
- 'assets-fill',
- 'assets-polygon-outline',
- 'assets-line',
'assets-point',
'sign-panels-circles',
'sign-panels-symbols',
'sign-poles-orientation',
'sign-poles',
'sign-poles-vertical',
+ 'assets-line',
+ 'assets-polygon-outline',
+ 'assets-fill',
].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
- const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
+ // Query with a tolerance bounding box so small point/circle symbols are easily selected
+ const tolerance = 6;
+ const pt = clickEvent.point;
+ const bbox = [
+ [pt.x - tolerance, pt.y - tolerance],
+ [pt.x + tolerance, pt.y + tolerance]
+ ];
+
+ const rendered = map.queryRenderedFeatures(bbox, { layers });
const unique = new Map();
rendered.forEach(f => {
@@ -2156,6 +2164,23 @@ function collectClickedAssets(clickEvent) {
if (id == null) return;
const key = `${p.thematic || ''}|${p.asset_type || ''}|${id}`;
if (unique.has(key)) return;
+
+ const geomType = f.geometry ? f.geometry.type : '';
+ const lType = f.layer ? f.layer.type : '';
+
+ let dist = 100;
+ if (geomType.includes('Point') && f.geometry.coordinates) {
+ try {
+ const coords = Array.isArray(f.geometry.coordinates[0]) ? f.geometry.coordinates[0] : f.geometry.coordinates;
+ const screenPt = map.project(coords);
+ dist = Math.hypot(pt.x - screenPt.x, pt.y - screenPt.y);
+ } catch (e) {
+ dist = 0;
+ }
+ } else if (geomType.includes('Line')) {
+ dist = 50;
+ }
+
unique.set(key, {
id: id,
code: p.code || '',
@@ -2170,6 +2195,10 @@ function collectClickedAssets(clickEvent) {
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
+ geometry_type: geomType,
+ layer_type: lType,
+ layer_id: f.layer ? f.layer.id : '',
+ click_dist: dist,
});
});
@@ -2177,10 +2206,17 @@ function collectClickedAssets(clickEvent) {
}
function collectClickedManagementZones(clickEvent) {
- const layers = ['management-zones-fill', 'management-zones-line'].filter(layerId => map.getLayer(layerId));
+ const layers = ['management-zones-line', 'management-zones-fill'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
- const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
+ const tolerance = 6;
+ const pt = clickEvent.point;
+ const bbox = [
+ [pt.x - tolerance, pt.y - tolerance],
+ [pt.x + tolerance, pt.y + tolerance]
+ ];
+
+ const rendered = map.queryRenderedFeatures(bbox, { layers });
const unique = new Map();
rendered.forEach(f => {
@@ -2201,6 +2237,9 @@ function collectClickedManagementZones(clickEvent) {
color: p.color || '',
feature_id: id,
is_management_zone: true,
+ geometry_type: f.geometry ? f.geometry.type : '',
+ layer_type: f.layer ? f.layer.type : '',
+ click_dist: 150,
});
});
@@ -2208,10 +2247,17 @@ function collectClickedManagementZones(clickEvent) {
}
function collectClickedLocations(clickEvent) {
- const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId));
+ const layers = ['locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'locations'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
- const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
+ const tolerance = 6;
+ const pt = clickEvent.point;
+ const bbox = [
+ [pt.x - tolerance, pt.y - tolerance],
+ [pt.x + tolerance, pt.y + tolerance]
+ ];
+
+ const rendered = map.queryRenderedFeatures(bbox, { layers });
const unique = new Map();
rendered.forEach(f => {
@@ -2220,6 +2266,21 @@ function collectClickedLocations(clickEvent) {
if (id == null) return;
const key = `${p.thematic || ''}|${id}`;
if (unique.has(key)) return;
+
+ const geomType = f.geometry ? f.geometry.type : '';
+ const lType = f.layer ? f.layer.type : '';
+
+ let dist = 100;
+ if (geomType.includes('Point') && f.geometry.coordinates) {
+ try {
+ const coords = Array.isArray(f.geometry.coordinates[0]) ? f.geometry.coordinates[0] : f.geometry.coordinates;
+ const screenPt = map.project(coords);
+ dist = Math.hypot(pt.x - screenPt.x, pt.y - screenPt.y);
+ } catch (e) {
+ dist = 0;
+ }
+ }
+
unique.set(key, {
id: id,
code: p.code || '',
@@ -2230,12 +2291,46 @@ function collectClickedLocations(clickEvent) {
usage_type_display: p.usage_type_display || '',
detail_url: p.detail_url || null,
feature_id: id,
+ is_location: true,
+ geometry_type: geomType,
+ layer_type: lType,
+ click_dist: dist,
});
});
return Array.from(unique.values());
}
+function sortClickedFeatures(features) {
+ return features.slice().sort((a, b) => {
+ const getGeomScore = (item) => {
+ const g = (item.geometry_type || '').toLowerCase();
+ const lt = (item.layer_type || '').toLowerCase();
+ if (g.includes('point') || lt === 'symbol' || lt === 'circle') return 1;
+ if (g.includes('line')) return 2;
+ return 3; // polygon / fill
+ };
+
+ const getTypeScore = (item) => {
+ if (item.is_management_zone) return 3;
+ if (item.is_location) return 2;
+ return 1; // assets
+ };
+
+ const gA = getGeomScore(a);
+ const gB = getGeomScore(b);
+ if (gA !== gB) return gA - gB;
+
+ const tA = getTypeScore(a);
+ const tB = getTypeScore(b);
+ if (tA !== tB) return tA - tB;
+
+ const dA = a.click_dist ?? 999;
+ const dB = b.click_dist ?? 999;
+ return dA - dB;
+ });
+}
+
function bindAssetPopupActions(popupNode) {
if (!popupNode) return;
@@ -2326,15 +2421,70 @@ function bindAssetPopupActions(popupNode) {
}
function showAssetClickPopup(clickEvent) {
- const assets = collectClickedAssets(clickEvent);
- const locations = collectClickedLocations(clickEvent);
- const managementZones = collectClickedManagementZones(clickEvent);
- if (!assets.length && !locations.length && !managementZones.length) return;
+ // 1. Never show popup during polygon selection, drawing mode, or right after polygon completes
+ if (mapSelectionMode === 'polygon') return;
+ if (mapDrawInstance && typeof mapDrawInstance.getMode === 'function') {
+ const mode = mapDrawInstance.getMode();
+ if (mode && mode.startsWith('draw')) return;
+ }
+ if (window._polygonSelectionJustFinished && (Date.now() - window._polygonSelectionJustFinished < 500)) {
+ return;
+ }
+
+ // 2. Prevent duplicate execution across multiple layer listeners on the same click
+ if (clickEvent && clickEvent.originalEvent) {
+ if (clickEvent.originalEvent._lokoClickHandled) return;
+ clickEvent.originalEvent._lokoClickHandled = true;
+ } else if (clickEvent && clickEvent.point) {
+ const now = Date.now();
+ if (window._lastMapClickTime && (now - window._lastMapClickTime < 50) &&
+ window._lastMapClickPoint &&
+ window._lastMapClickPoint.x === clickEvent.point.x &&
+ window._lastMapClickPoint.y === clickEvent.point.y) {
+ return;
+ }
+ window._lastMapClickTime = now;
+ window._lastMapClickPoint = clickEvent.point;
+ }
+
+ const rawAssets = collectClickedAssets(clickEvent);
+ const rawLocations = collectClickedLocations(clickEvent);
+ const rawManagementZones = collectClickedManagementZones(clickEvent);
+
+ const assets = sortClickedFeatures(rawAssets);
+ const locations = sortClickedFeatures(rawLocations);
+ const managementZones = sortClickedFeatures(rawManagementZones);
+
+ const allCandidates = sortClickedFeatures([...assets, ...locations, ...managementZones]);
+ if (!allCandidates.length) return;
if (mapSelectionMode === 'click') {
- const target = assets[0] || locations[0] || managementZones[0];
+ let target = allCandidates[0];
+ // If there are multiple candidates of the same geometry category (e.g. two overlapping points),
+ // and the topmost one is already selected, allow selecting the next unselected one so both can be selected.
+ if (allCandidates.length > 1) {
+ const topGeom = (target.geometry_type || '').toLowerCase();
+ const isTopPoint = topGeom.includes('point');
+ const isTopSelected = selectedIds.has(target.id) || selectedIds.has(String(target.id));
+
+ if (isTopSelected) {
+ const sameGeomUnselected = allCandidates.find(c => {
+ const cGeom = (c.geometry_type || '').toLowerCase();
+ const isCPoint = cGeom.includes('point');
+ if (isTopPoint !== isCPoint) return false;
+ return !selectedIds.has(c.id) && !selectedIds.has(String(c.id));
+ });
+ if (sameGeomUnselected) {
+ target = sameGeomUnselected;
+ }
+ }
+ }
+
if (target && target.id != null) {
toggleAssetSelection(target.id, target);
+ const isNowSel = selectedIds.has(target.id) || selectedIds.has(String(target.id)) || (typeof target.id === 'string' && !isNaN(target.id) && selectedIds.has(Number(target.id)));
+ const label = target.code || target.name || target.element_type || gettext('Asset');
+ showToast(`${label} : ${isNowSel ? gettext('sélectionné') : gettext('désélectionné')}`, isNowSel ? 'primary' : 'secondary');
}
return;
}
@@ -2999,8 +3149,8 @@ function resetAllHighlightStates(specificId = null) {
function setMapFeatureSelectionState(rowOrId, isSelected) {
if (typeof map === 'undefined' || !map) return;
const isObj = rowOrId && typeof rowOrId === 'object';
- const id = isObj ? rowOrId.id : rowOrId;
- if (id == null) return;
+ const rawId = isObj ? rowOrId.id : rowOrId;
+ if (rawId == null) return;
let targetSource = null;
if (isObj) {
@@ -3008,21 +3158,29 @@ function setMapFeatureSelectionState(rowOrId, isSelected) {
}
const sources = targetSource ? [targetSource] : ['assets', 'locations', 'management_zones'];
+ const idsToSet = [rawId];
+ if (typeof rawId === 'string' && !isNaN(rawId) && rawId.trim() !== '') {
+ idsToSet.push(Number(rawId));
+ } else if (typeof rawId === 'number') {
+ idsToSet.push(String(rawId));
+ }
sources.forEach(src => {
try {
if (map.getSource(src)) {
- if (isSelected) {
- map.setFeatureState({ source: src, id: id }, { selected: true });
- } else {
- map.setFeatureState({ source: src, id: id }, { selected: false, highlighted: false });
- }
+ idsToSet.forEach(fid => {
+ if (isSelected) {
+ map.setFeatureState({ source: src, id: fid }, { selected: true });
+ } else {
+ map.setFeatureState({ source: src, id: fid }, { selected: false, highlighted: false });
+ }
+ });
}
} catch (e) {}
});
if (!isSelected) {
- resetAllHighlightStates(id);
+ resetAllHighlightStates(rawId);
}
}
@@ -3300,8 +3458,14 @@ maploader.addEventListener('mapLoaded', () => {
if (!map.getLayer(layerId)) return;
map.on('click', layerId, showAssetClickPopup);
if (['assets', 'assets-point-selection', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'management-zones-fill'].includes(layerId)) {
- map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
- map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
+ map.on('mouseenter', layerId, function () {
+ if (mapSelectionMode === 'polygon') return;
+ map.getCanvas().style.cursor = 'pointer';
+ });
+ map.on('mouseleave', layerId, function () {
+ if (mapSelectionMode === 'polygon') return;
+ map.getCanvas().style.cursor = '';
+ });
}
});
@@ -3717,6 +3881,7 @@ function selectAssetsInsidePolygon(polygonGeom) {
}
function handlePolygonSelectionCreated(e) {
+ window._polygonSelectionJustFinished = Date.now();
if (!e || !e.features || !e.features.length) return;
const geom = e.features[0].geometry;
const count = selectAssetsInsidePolygon(geom);
@@ -3743,6 +3908,10 @@ function handleDrawModeChange(e) {
function setMapSelectionMode(mode) {
mapSelectionMode = mode;
+ if (assetClickPopup && assetClickPopup.isOpen()) {
+ assetClickPopup.remove();
+ }
+
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');
diff --git a/loko/assets/templates/assets/unified_index.html b/loko/assets/templates/assets/unified_index.html
index 29062c6..9c68a79 100644
--- a/loko/assets/templates/assets/unified_index.html
+++ b/loko/assets/templates/assets/unified_index.html
@@ -1683,5 +1683,5 @@
-
+
{% endblock script %}
\ No newline at end of file