import { AppLauncher } from '@capacitor/app-launcher'; import { Browser } from '@capacitor/browser'; import { Capacitor } from '@capacitor/core'; import L from 'leaflet'; const activeLiveMaps = new Set(); ['hashchange', 'pagehide', 'beforeunload'].forEach((eventName) => { window.addEventListener(eventName, () => { destroyAllLiveRouteMaps(); }); }); export function normalizeCoordinate(value) { const normalized = String(value ?? '').trim().replace(',', '.'); if (!normalized) return null; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } export function hasValidCoordinates(lat, lng) { const latitude = normalizeCoordinate(lat); const longitude = normalizeCoordinate(lng); if (latitude === null || longitude === null) return false; if (latitude < -90 || latitude > 90) return false; if (longitude < -180 || longitude > 180) return false; return true; } export function formatCoordinates(lat, lng) { if (!hasValidCoordinates(lat, lng)) return ''; return `${normalizeCoordinate(lat).toFixed(6)}, ${normalizeCoordinate(lng).toFixed(6)}`; } export function buildMapEmbedUrl(lat, lng) { const latitude = normalizeCoordinate(lat); const longitude = normalizeCoordinate(lng); const delta = 0.012; const bbox = [longitude - delta, latitude - delta, longitude + delta, latitude + delta] .map((value) => encodeURIComponent(String(value))) .join('%2C'); return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${encodeURIComponent(`${latitude},${longitude}`)}`; } export function buildExternalMapUrl(lat, lng, label = '') { const latitude = normalizeCoordinate(lat); const longitude = normalizeCoordinate(lng); const destination = `${latitude},${longitude}`; const queryLabel = label ? `${destination} (${label})` : destination; return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(queryLabel)}`; } export function buildDirectionsUrl(lat, lng, label = '') { const latitude = normalizeCoordinate(lat); const longitude = normalizeCoordinate(lng); const destination = `${latitude},${longitude}`; const queryLabel = label ? `${destination} (${label})` : destination; return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(queryLabel)}&travelmode=driving`; } export function openExternalMap(lat, lng, label = '') { if (!hasValidCoordinates(lat, lng)) return false; const fallbackUrl = buildExternalMapUrl(lat, lng, label); void openExternalUrl(fallbackUrl, { preferExternalApp: isNativeAndroid() }); return true; } export function launchDirections(lat, lng, label = '') { if (!hasValidCoordinates(lat, lng)) return false; const latitude = normalizeCoordinate(lat); const longitude = normalizeCoordinate(lng); const navigationIntent = `google.navigation:q=${latitude},${longitude}&mode=d`; const fallbackUrl = buildDirectionsUrl(latitude, longitude, label); if (isNativeAndroid()) { void openNativeDirections(navigationIntent, fallbackUrl); return true; } void openExternalUrl(fallbackUrl); return true; } export function mountLiveRouteMap({ mapElement, destinationLat, destinationLng, destinationLabel = '', statusElement, distanceElement, durationElement, technicianCoordsElement, }) { if (!mapElement || !hasValidCoordinates(destinationLat, destinationLng)) return null; destroyLiveRouteMap(mapElement); const destination = [normalizeCoordinate(destinationLat), normalizeCoordinate(destinationLng)]; const map = L.map(mapElement, { zoomControl: true, attributionControl: true, }).setView(destination, 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); const destinationMarker = L.circleMarker(destination, { radius: 10, color: '#0f6cbd', weight: 3, fillColor: '#22c1c3', fillOpacity: 0.85, }).addTo(map).bindPopup(destinationLabel || 'Destination client'); let technicianMarker = null; let accuracyCircle = null; let routeLine = null; let destroyed = false; let watchId = null; let lastOrigin = null; let lastRouteDistance = null; let lastRouteDuration = null; setMapStatus(statusElement, 'Recherche de votre position GPS…', 'secondary'); setMapMetric(distanceElement, 'En attente'); setMapMetric(durationElement, 'En attente'); setMapMetric(technicianCoordsElement, 'Position en cours de détection'); requestMapRefresh(map, () => !destroyed); const controller = { destroy() { if (destroyed) return; destroyed = true; if (watchId !== null && navigator.geolocation) { navigator.geolocation.clearWatch(watchId); } if (routeLine) map.removeLayer(routeLine); if (accuracyCircle) map.removeLayer(accuracyCircle); if (technicianMarker) map.removeLayer(technicianMarker); map.remove(); activeLiveMaps.delete(controller); if (mapElement.__liveRouteController === controller) { delete mapElement.__liveRouteController; } }, }; mapElement.__liveRouteController = controller; activeLiveMaps.add(controller); if (!navigator.geolocation) { setMapStatus(statusElement, 'La géolocalisation n’est pas disponible sur cet appareil.', 'warning'); return controller; } const updateRoute = async (origin) => { const directDistance = calculateDistanceMeters(origin[0], origin[1], destination[0], destination[1]); setMapMetric(distanceElement, formatDistance(directDistance)); setMapMetric(technicianCoordsElement, formatCoordinates(origin[0], origin[1])); const shouldRefreshRoute = !lastOrigin || calculateDistanceMeters(origin[0], origin[1], lastOrigin[0], lastOrigin[1]) > 20; if (!shouldRefreshRoute && lastRouteDistance !== null) { setMapMetric(distanceElement, formatDistance(lastRouteDistance)); setMapMetric(durationElement, formatDuration(lastRouteDuration)); setMapStatus(statusElement, 'Trajet actualisé depuis votre position.', 'success'); fitMap(map, technicianMarker, destinationMarker, routeLine); return; } lastOrigin = origin; setMapStatus(statusElement, 'Calcul de l’itinéraire en cours…', 'secondary'); try { const routeUrl = `https://router.project-osrm.org/route/v1/driving/${origin[1]},${origin[0]};${destination[1]},${destination[0]}?overview=full&geometries=geojson`; const response = await fetch(routeUrl); if (!response.ok) throw new Error('route_http'); const payload = await response.json(); const route = payload?.routes?.[0] || null; if (!route?.geometry?.coordinates?.length) throw new Error('route_empty'); if (routeLine) { map.removeLayer(routeLine); } routeLine = L.geoJSON(route.geometry, { style: { color: '#0f6cbd', weight: 5, opacity: 0.9, }, }).addTo(map); lastRouteDistance = Number(route.distance || directDistance); lastRouteDuration = Number(route.duration || 0); setMapMetric(distanceElement, formatDistance(lastRouteDistance)); setMapMetric(durationElement, formatDuration(lastRouteDuration)); setMapStatus(statusElement, 'Itinéraire temps réel prêt.', 'success'); fitMap(map, technicianMarker, destinationMarker, routeLine); } catch { lastRouteDistance = directDistance; lastRouteDuration = null; setMapMetric(distanceElement, formatDistance(directDistance)); setMapMetric(durationElement, 'Non disponible'); setMapStatus(statusElement, 'Position récupérée, mais le calcul d’itinéraire n’a pas abouti.', 'warning'); fitMap(map, technicianMarker, destinationMarker, routeLine); } }; watchId = navigator.geolocation.watchPosition((position) => { if (destroyed) return; const origin = [position.coords.latitude, position.coords.longitude]; if (!technicianMarker) { technicianMarker = L.circleMarker(origin, { radius: 9, color: '#15803d', weight: 3, fillColor: '#22c55e', fillOpacity: 0.82, }).addTo(map).bindPopup('Votre position'); } else { technicianMarker.setLatLng(origin); } if (!accuracyCircle) { accuracyCircle = L.circle(origin, { radius: Math.max(Number(position.coords.accuracy || 20), 15), color: '#15803d', weight: 1, fillColor: '#22c55e', fillOpacity: 0.12, }).addTo(map); } else { accuracyCircle.setLatLng(origin); accuracyCircle.setRadius(Math.max(Number(position.coords.accuracy || 20), 15)); } updateRoute(origin); }, () => { if (destroyed) return; setMapStatus(statusElement, 'Impossible de récupérer votre position GPS en direct.', 'danger'); setMapMetric(technicianCoordsElement, 'Autorisation ou GPS indisponible'); }, { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000, }); return controller; } export function destroyLiveRouteMap(mapElement) { mapElement?.__liveRouteController?.destroy(); } export function calculateDistanceMeters(startLat, startLng, endLat, endLng) { const earthRadius = 6371000; const dLat = toRadians(endLat - startLat); const dLng = toRadians(endLng - startLng); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(startLat)) * Math.cos(toRadians(endLat)) * Math.sin(dLng / 2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthRadius * c; } function destroyAllLiveRouteMaps() { Array.from(activeLiveMaps).forEach((controller) => controller.destroy()); } function fitMap(map, technicianMarker, destinationMarker, routeLine) { if (routeLine) { map.fitBounds(routeLine.getBounds(), { padding: [18, 18] }); return; } const bounds = L.latLngBounds([]); if (technicianMarker) bounds.extend(technicianMarker.getLatLng()); if (destinationMarker) bounds.extend(destinationMarker.getLatLng()); if (bounds.isValid()) { map.fitBounds(bounds, { padding: [22, 22] }); } } function setMapStatus(element, text, tone) { if (!element) return; element.textContent = text; element.className = `mobile-route-status tone-${tone || 'secondary'}`; } function setMapMetric(element, text) { if (!element) return; element.textContent = text; } function formatDistance(distanceMeters) { if (!Number.isFinite(distanceMeters)) return 'Non disponible'; if (distanceMeters >= 1000) return `${(distanceMeters / 1000).toFixed(1)} km`; return `${Math.round(distanceMeters)} m`; } function formatDuration(durationSeconds) { if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return 'Non disponible'; const totalMinutes = Math.round(durationSeconds / 60); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours > 0) return `${hours} h ${String(minutes).padStart(2, '0')} min`; return `${totalMinutes} min`; } function toRadians(value) { return (value * Math.PI) / 180; } async function openNativeDirections(navigationIntent, fallbackUrl) { try { await AppLauncher.openUrl({ url: navigationIntent }); return; } catch { await openExternalUrl(fallbackUrl, { preferExternalApp: true }); } } async function openExternalUrl(url, { preferExternalApp = false } = {}) { if (!url) return false; if (isNativePlatform()) { if (preferExternalApp) { try { await AppLauncher.openUrl({ url }); return true; } catch { } } try { await Browser.open({ url }); return true; } catch { } } if (typeof window.open === 'function') { window.open(url, '_blank', 'noopener'); return true; } window.location.href = url; return true; } function requestMapRefresh(map, isAlive) { const refresh = () => { if (!isAlive()) return; map.invalidateSize(); }; if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(() => { window.requestAnimationFrame(refresh); }); } window.setTimeout(refresh, 180); window.setTimeout(refresh, 420); } function isNativePlatform() { return typeof Capacitor?.isNativePlatform === 'function' && Capacitor.isNativePlatform(); } function isNativeAndroid() { if (!isNativePlatform()) return false; if (typeof Capacitor?.getPlatform !== 'function') return false; return Capacitor.getPlatform() === 'android'; }