feat: add continuous GPS tracking, pulsing location marker, and distance calculation to inspection maps

This commit is contained in:
kdeterme 2026-09-25 14:23:51 +02:00
parent 97ecd8d9fb
commit c8349a3171
3 changed files with 255 additions and 31 deletions

View file

@ -290,7 +290,7 @@
'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif', 'oed'], '#8d6e63',
['noue', 'jardin de pluie', 'bande filtrante', 'evc'], '#009688',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#0277bd',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#455a64',
'#0288d1'
],
'line-width': 2.5
@ -343,11 +343,13 @@
['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40',
'#198754'
],
['match', ['coalesce', ['get', 'model_name'], ''], ['roaddrain', 'drain'], true, false], '#37474f',
[
'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
['jardin de pluie', 'noue', 'evc'], '#009688',
['bassin orage', 'citerne', 'ta'], '#0277bd',
['avaloir', 'avaloir de rail', 'bouche injection', 'filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'puits infiltration', 'drain', 'regard', 'vanne regulation', 'vanne isolation'], '#37474f',
'#0288d1'
]
],
@ -731,51 +733,99 @@
}, 400);
}
let gpsWatchId = null;
let isFirstGpsFix = true;
function setupGeolocation() {
const gpsBtn = document.getElementById('btn-gps-locate');
if (gpsBtn) {
gpsBtn.addEventListener('click', locateUser);
gpsBtn.addEventListener('click', function() {
if (window.currentGpsPosition && map) {
map.flyTo({
center: [window.currentGpsPosition.lng, window.currentGpsPosition.lat],
zoom: 18,
duration: 800
});
} else {
startGpsTracking(true);
}
});
}
const hasPreselect = (window.PRESELECT_LON !== null && window.PRESELECT_LAT !== null && typeof window.PRESELECT_LON !== 'undefined' && typeof window.PRESELECT_LAT !== 'undefined');
if (!hasPreselect) {
locateUser();
startGpsTracking(!hasPreselect);
window.addEventListener('beforeunload', stopGpsTracking);
}
function stopGpsTracking() {
if (gpsWatchId !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
}
function locateUser() {
startGpsTracking(true);
}
function startGpsTracking(shouldFlyTo = false) {
if (!navigator.geolocation) return;
const i18n = window.INSPECTION_MAP_I18N || {};
const statusText = document.getElementById('gps-status-text');
if (statusText) statusText.textContent = i18n.geolocation || (typeof gettext !== 'undefined' ? gettext('Géolocalisation...') : 'Géolocalisation...');
navigator.geolocation.getCurrentPosition(
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
gpsWatchId = navigator.geolocation.watchPosition(
function(position) {
const lng = position.coords.longitude;
const lat = position.coords.latitude;
const accuracy = position.coords.accuracy;
window.currentGpsPosition = { lng, lat, accuracy, timestamp: Date.now() };
try {
window.dispatchEvent(new CustomEvent('loko:gps-update', {
detail: window.currentGpsPosition
}));
} catch(e) {}
if (typeof maplibregl !== 'undefined' && maplibregl.Marker && map) {
if (userMarker) {
userMarker.setLngLat([lng, lat]);
} else {
const el = document.createElement('div');
el.className = 'user-gps-dot';
el.className = 'user-gps-dot-container';
el.innerHTML = '<div class="user-gps-dot-pulse"></div><div class="user-gps-dot"></div>';
userMarker = new maplibregl.Marker({ element: el })
userMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([lng, lat])
.addTo(map);
}
map.flyTo({ center: [lng, lat], zoom: 17 });
if (shouldFlyTo || isFirstGpsFix) {
map.flyTo({ center: [lng, lat], zoom: 18, duration: 1000 });
isFirstGpsFix = false;
shouldFlyTo = false;
}
}
if (statusText) {
statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
}
if (statusText) statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
},
function(err) {
console.warn('GPS error:', err.message);
if (statusText) statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
console.warn('GPS watchPosition error:', err.message);
if (statusText) {
statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 2000 }
);
}

View file

@ -95,8 +95,12 @@
</div>
</div>
<!-- Conteneur Carte Agrandie -->
<div id="giep_modal_map_container" class="rounded-3 border overflow-hidden" style="height: 480px; max-height: 68vh; position: relative;">
<div id="giep_modal_map_container" class="rounded-3 border overflow-hidden position-relative" style="height: 480px; max-height: 68vh;">
<div id="giepModalMap" style="width: 100%; height: 100%;"></div>
<!-- Bouton Recentrer sur ma position GPS -->
<button type="button" id="giep_modal_recenter_user_btn" class="btn btn-light btn-sm shadow rounded-circle border position-absolute d-flex align-items-center justify-content-center" style="bottom: 16px; right: 16px; z-index: 10; width: 44px; height: 44px;" title="{% translate 'Ma position GPS' %}">
<i class="bi bi-crosshair text-primary fs-5"></i>
</button>
</div>
</div>
<div class="modal-footer py-2 px-3 bg-light">
@ -153,6 +157,39 @@
.btn-seg:hover {
transform: translateY(-1px);
}
.user-gps-dot-container {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.user-gps-dot-pulse {
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(111, 66, 193, 0.35);
animation: gpsPulse 2s infinite ease-out;
pointer-events: none;
}
.user-gps-dot {
position: relative;
width: 16px;
height: 16px;
background-color: #6f42c1;
border: 2.5px solid #ffffff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(111, 66, 193, 0.65), 0 0 0 1px rgba(0, 0, 0, 0.15);
z-index: 2;
}
@keyframes gpsPulse {
0% { transform: scale(0.5); opacity: 1; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
</style>
<script>
@ -460,6 +497,18 @@
let currentMapPopup = null;
function computeDistanceMeters(lat1, lon1, lat2, lon2) {
if (!lat1 || !lon1 || !lat2 || !lon2) return null;
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function showAssetPopup(asset, lngLat) {
if (!modalMapInstance) return;
if (currentMapPopup) {
@ -474,6 +523,18 @@
else if (devType === 'ta') badgeHtml = '<span class="badge bg-teal-subtle text-teal text-uppercase me-1">TA</span>';
else badgeHtml = '<span class="badge bg-secondary-subtle text-secondary text-uppercase me-1">Équipement</span>';
let distanceBadgeHtml = '';
if (window.currentGpsPosition && lngLat) {
const d = computeDistanceMeters(
window.currentGpsPosition.lat, window.currentGpsPosition.lng,
lngLat.lat, lngLat.lng
);
if (d !== null && !isNaN(d)) {
const distStr = d < 1000 ? `${Math.round(d)} m` : `${(d / 1000).toFixed(1)} km`;
distanceBadgeHtml = `<div class="mb-2"><span class="badge bg-light text-primary border extra-small d-inline-flex align-items-center gap-1 shadow-2xs"><i class="bi bi-geo-alt-fill text-danger"></i>~ ${distStr}</span></div>`;
}
}
const popupNode = document.createElement('div');
popupNode.className = 'p-1';
popupNode.innerHTML = `
@ -486,6 +547,7 @@
${badgeHtml}
<span>${asset.structure_type || ''}</span>
</div>
${distanceBadgeHtml}
<button type="button" class="btn btn-primary btn-sm w-100 rounded-pill py-1.5 px-3 fw-semibold select-popup-btn">
<i class="bi bi-check-circle me-1"></i>{% filter escapejs %}{% translate "Sélectionner" %}{% endfilter %}
</button>
@ -1055,10 +1117,14 @@
if (matchText && matchType && asset.rawFeature && typeof maplibregl !== 'undefined') {
const geom = asset.rawFeature.geometry;
let color = '#0277bd'; // Bleu GIEP (naturerwiasset)
let color = '#0277bd'; // Bleu GIEP par défaut
const descStr = ((asset.structure_type || '') + ' ' + (asset.name || '')).toLowerCase();
const isPointAccess = descStr.includes('avaloir') || descStr.includes('injection') || descStr.includes('regard') || descStr.includes('grille') || descStr.includes('caniveau') || descStr.includes('filet eau');
if (devType === 'evc') color = '#2e7d32'; // Vert EVC
if (devType === 'oed') color = '#0277bd'; // Bleu OED
if (devType === 'ta') color = '#00796b'; // Turquoise TA
else if (devType === 'oed') color = '#8d6e63'; // Brun OED
else if (devType === 'ta') color = '#00796b'; // Turquoise TA
else if (isPointAccess) color = '#37474f'; // Gris ardoise (avaloir, regard, bouche d'injection)
if (geom) {
const featureClone = {
@ -1204,9 +1270,65 @@
});
}
// Cadrer la carte sur les assets
// Cadrer la carte sur les assets et la position GPS
if (hasCoords && bounds && !bounds.isEmpty()) {
modalMapInstance.fitBounds(bounds, { padding: 50, maxZoom: 18, duration: 300 });
if (window.currentGpsPosition) {
const userLng = window.currentGpsPosition.lng;
const userLat = window.currentGpsPosition.lat;
const centerLngLat = bounds.getCenter();
const distToCenter = computeDistanceMeters(userLat, userLng, centerLngLat.lat, centerLngLat.lng);
if (distToCenter !== null && distToCenter < 1200) {
bounds.extend([userLng, userLat]);
}
}
modalMapInstance.fitBounds(bounds, { padding: 50, maxZoom: 18, duration: 400 });
}
}
let modalMapUserMarker = null;
let modalGpsWatchId = null;
function updateModalMapGpsPosition(pos) {
if (!pos || !modalMapInstance) return;
const lng = pos.lng || pos.coords?.longitude;
const lat = pos.lat || pos.coords?.latitude;
if (!lng || !lat) return;
if (modalMapUserMarker) {
modalMapUserMarker.setLngLat([lng, lat]);
} else if (typeof maplibregl !== 'undefined') {
const el = document.createElement('div');
el.className = 'user-gps-dot-container';
el.innerHTML = '<div class="user-gps-dot-pulse"></div><div class="user-gps-dot"></div>';
modalMapUserMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([lng, lat])
.addTo(modalMapInstance);
}
}
function startModalGpsTracking() {
if (!navigator.geolocation) return;
if (modalGpsWatchId !== null) return;
modalGpsWatchId = navigator.geolocation.watchPosition(
(position) => {
const pos = {
lng: position.coords.longitude,
lat: position.coords.latitude,
accuracy: position.coords.accuracy,
timestamp: Date.now()
};
window.currentGpsPosition = pos;
updateModalMapGpsPosition(pos);
},
(err) => console.warn('Modal GPS watchPosition error:', err.message),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 2000 }
);
}
function stopModalGpsTracking() {
if (modalGpsWatchId !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(modalGpsWatchId);
modalGpsWatchId = null;
}
}
@ -1220,30 +1342,27 @@
modalMapInstance = new maplibregl.Map({
container: 'giepModalMap',
style: style,
center: [4.3517, 50.8503],
zoom: 15,
center: (window.currentGpsPosition) ? [window.currentGpsPosition.lng, window.currentGpsPosition.lat] : [4.3517, 50.8503],
zoom: 16,
antialias: true
});
// Contrôles de navigation et de géolocalisation
// Contrôles de navigation
modalMapInstance.addControl(new maplibregl.NavigationControl(), 'top-right');
const geolocate = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showUserLocation: true
});
modalMapInstance.addControl(geolocate, 'top-right');
modalMapInstance.on('load', () => {
updateModalMapGpsPosition(window.currentGpsPosition);
renderModalMapFeatures(true);
});
} else {
setTimeout(() => {
modalMapInstance.resize();
updateModalMapGpsPosition(window.currentGpsPosition);
renderModalMapFeatures(true);
}, 150);
}
startModalGpsTracking();
}
function openMapModal(idx) {
@ -2031,10 +2150,40 @@
});
modalEl.addEventListener('hidden.bs.modal', (e) => {
e.stopPropagation();
stopModalGpsTracking();
toggleParentGlobalSections(true);
});
}
window.addEventListener('loko:gps-update', (e) => {
updateModalMapGpsPosition(e.detail);
});
const recenterBtn = rootModal.querySelector('#giep_modal_recenter_user_btn') || document.getElementById('giep_modal_recenter_user_btn');
if (recenterBtn) {
recenterBtn.addEventListener('click', () => {
if (window.currentGpsPosition && modalMapInstance) {
modalMapInstance.flyTo({
center: [window.currentGpsPosition.lng, window.currentGpsPosition.lat],
zoom: 18,
duration: 800
});
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
const lng = pos.coords.longitude;
const lat = pos.coords.latitude;
window.currentGpsPosition = { lng, lat, accuracy: pos.coords.accuracy, timestamp: Date.now() };
updateModalMapGpsPosition(window.currentGpsPosition);
if (modalMapInstance) {
modalMapInstance.flyTo({ center: [lng, lat], zoom: 18, duration: 800 });
}
}, err => {
console.warn('GPS error:', err);
}, { enableHighAccuracy: true, timeout: 8000 });
}
});
}
const searchInput = rootModal.querySelector('#giep_modal_search_input, .giep-modal-search-input');
const typeFilter = rootModal.querySelector('#giep_modal_type_filter, .giep-modal-type-filter');

View file

@ -61,13 +61,38 @@
flex-direction: column;
gap: 8px;
}
.user-gps-dot-container {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.user-gps-dot-pulse {
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(111, 66, 193, 0.35);
animation: gpsPulse 2s infinite ease-out;
pointer-events: none;
}
.user-gps-dot {
position: relative;
width: 16px;
height: 16px;
background-color: #0d6efd;
border: 2px solid #ffffff;
background-color: #6f42c1;
border: 2.5px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 10px rgba(13, 110, 253, 0.8);
box-shadow: 0 2px 8px rgba(111, 66, 193, 0.65), 0 0 0 1px rgba(0, 0, 0, 0.15);
z-index: 2;
}
@keyframes gpsPulse {
0% { transform: scale(0.5); opacity: 1; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
/* Bottom Sheet Modal Styling for Mobile */