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