Added Calendar Page
This commit is contained in:
429
components/TrajetMap.tsx
Normal file
429
components/TrajetMap.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix pour les icônes Leaflet avec Next.js
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
});
|
||||
}
|
||||
|
||||
// Import dynamique pour éviter les problèmes SSR avec Leaflet
|
||||
const MapContainer = dynamic(() => import('react-leaflet').then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import('react-leaflet').then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import('react-leaflet').then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import('react-leaflet').then((mod) => mod.Popup), { ssr: false });
|
||||
const Polyline = dynamic(() => import('react-leaflet').then((mod) => mod.Polyline), { ssr: false });
|
||||
|
||||
interface TrajetMapProps {
|
||||
adresseDepart: string;
|
||||
adresseArrivee: string;
|
||||
adherentNom?: string;
|
||||
}
|
||||
|
||||
interface Coordinates {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface RouteInfo {
|
||||
distance: number; // en mètres
|
||||
duration: number; // en secondes
|
||||
}
|
||||
|
||||
export default function TrajetMap({ adresseDepart, adresseArrivee, adherentNom }: TrajetMapProps) {
|
||||
const [departCoords, setDepartCoords] = useState<Coordinates | null>(null);
|
||||
const [arriveeCoords, setArriveeCoords] = useState<Coordinates | null>(null);
|
||||
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Cache simple pour éviter de regéocoder les mêmes adresses
|
||||
const geocodeCacheRef = useRef<Map<string, Coordinates>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// Réinitialiser les coordonnées quand les adresses changent
|
||||
setDepartCoords(null);
|
||||
setArriveeCoords(null);
|
||||
setRouteInfo(null);
|
||||
setError(null);
|
||||
|
||||
if (adresseDepart && adresseArrivee && adresseDepart.length >= 5 && adresseArrivee.length >= 5) {
|
||||
// Délai réduit pour une réponse plus rapide
|
||||
const timeoutId = setTimeout(() => {
|
||||
geocodeAddresses();
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [adresseDepart, adresseArrivee]);
|
||||
|
||||
const geocodeAddresses = async () => {
|
||||
if (!adresseDepart || !adresseArrivee) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que les adresses ont au moins 5 caractères
|
||||
if (adresseDepart.length < 5 || adresseArrivee.length < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Géocoder les deux adresses en parallèle pour plus de rapidité
|
||||
// (l'API Nominatim permet généralement 1 req/sec, mais on peut essayer)
|
||||
const [departResult, arriveeResult] = await Promise.all([
|
||||
geocodeAddress(adresseDepart).catch(() => null),
|
||||
// Petit délai pour la deuxième requête pour respecter les limites
|
||||
new Promise(resolve => setTimeout(resolve, 1000)).then(() =>
|
||||
geocodeAddress(adresseArrivee).catch(() => null)
|
||||
),
|
||||
]);
|
||||
|
||||
if (departResult && arriveeResult) {
|
||||
setDepartCoords(departResult);
|
||||
setArriveeCoords(arriveeResult);
|
||||
|
||||
// Calculer la distance et le temps estimé
|
||||
const info = calculateRouteInfo(departResult, arriveeResult);
|
||||
setRouteInfo(info);
|
||||
} else if (!departResult) {
|
||||
setError(`Impossible de trouver l'adresse de départ: "${adresseDepart}"`);
|
||||
} else {
|
||||
setError(`Impossible de trouver l'adresse d'arrivée: "${adresseArrivee}"`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du géocodage:', err);
|
||||
setError('Erreur lors du chargement de la carte. Veuillez réessayer.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const geocodeAddress = async (address: string): Promise<Coordinates | null> => {
|
||||
if (!address || address.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Nettoyer l'adresse pour améliorer les résultats
|
||||
const cleanAddress = address.trim().toLowerCase();
|
||||
|
||||
// Vérifier le cache
|
||||
if (geocodeCacheRef.current.has(cleanAddress)) {
|
||||
return geocodeCacheRef.current.get(cleanAddress)!;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address.trim())}&limit=1&addressdetails=1&countrycodes=fr`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'MAD Platform',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
'Referer': window.location.origin,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0 && data[0].lat && data[0].lon) {
|
||||
const lat = parseFloat(data[0].lat);
|
||||
const lng = parseFloat(data[0].lon);
|
||||
|
||||
// Vérifier que les coordonnées sont valides
|
||||
if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
const coords = { lat, lng };
|
||||
// Mettre en cache (limiter à 50 entrées pour éviter la surconsommation mémoire)
|
||||
if (geocodeCacheRef.current.size < 50) {
|
||||
geocodeCacheRef.current.set(cleanAddress, coords);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('Réponse non OK de Nominatim:', response.status);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Erreur de géocodage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRouteInfo = (depart: Coordinates, arrivee: Coordinates): RouteInfo => {
|
||||
// Calcul de la distance à vol d'oiseau (formule de Haversine)
|
||||
const R = 6371e3; // Rayon de la Terre en mètres
|
||||
const φ1 = (depart.lat * Math.PI) / 180;
|
||||
const φ2 = (arrivee.lat * Math.PI) / 180;
|
||||
const Δφ = ((arrivee.lat - depart.lat) * Math.PI) / 180;
|
||||
const Δλ = ((arrivee.lng - depart.lng) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
const distance = R * c; // Distance en mètres
|
||||
|
||||
// Estimation du temps : vitesse moyenne de 50 km/h en ville
|
||||
// On multiplie la distance par 1.3 pour tenir compte des détours
|
||||
const distanceWithDetour = distance * 1.3;
|
||||
const vitesseMoyenne = 50; // km/h
|
||||
const duration = (distanceWithDetour / 1000 / vitesseMoyenne) * 3600; // en secondes
|
||||
|
||||
return { distance: distanceWithDetour, duration };
|
||||
};
|
||||
|
||||
const formatDistance = (meters: number): string => {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes > 0 ? minutes : ''}`;
|
||||
}
|
||||
return `${minutes} min`;
|
||||
};
|
||||
|
||||
if (!adresseDepart || !adresseArrivee) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<p className="text-sm">Remplissez les adresses pour voir la carte</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-white rounded-xl">
|
||||
<div className="text-center">
|
||||
<svg className="animate-spin h-12 w-12 text-lblue mx-auto mb-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Chargement de la carte...</p>
|
||||
<p className="text-xs text-gray-500">Géocodage des adresses en cours</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 bg-white rounded-xl p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<svg className="w-20 h-20 mx-auto mb-4 opacity-50 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">{error}</p>
|
||||
<p className="text-xs text-gray-500 mb-4">Vérifiez que les adresses sont correctes et complètes</p>
|
||||
<button
|
||||
onClick={geocodeAddresses}
|
||||
className="px-4 py-2 text-xs font-medium text-white bg-lblue rounded-lg hover:bg-dblue transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!departCoords || !arriveeCoords) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<p className="text-sm">Chargement de la carte...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculer le centre et le zoom optimal pour afficher les deux points
|
||||
const centerLat = departCoords && arriveeCoords ? (departCoords.lat + arriveeCoords.lat) / 2 : 48.8566;
|
||||
const centerLng = departCoords && arriveeCoords ? (departCoords.lng + arriveeCoords.lng) / 2 : 2.3522;
|
||||
|
||||
// Calculer le zoom optimal pour voir les deux points
|
||||
let optimalZoom = 12;
|
||||
if (departCoords && arriveeCoords) {
|
||||
const latDiff = Math.abs(departCoords.lat - arriveeCoords.lat);
|
||||
const lngDiff = Math.abs(departCoords.lng - arriveeCoords.lng);
|
||||
const maxDiff = Math.max(latDiff, lngDiff);
|
||||
|
||||
if (maxDiff > 0.5) optimalZoom = 7;
|
||||
else if (maxDiff > 0.2) optimalZoom = 8;
|
||||
else if (maxDiff > 0.1) optimalZoom = 9;
|
||||
else if (maxDiff > 0.05) optimalZoom = 10;
|
||||
else if (maxDiff > 0.02) optimalZoom = 11;
|
||||
else optimalZoom = 12;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-[500px]">
|
||||
{/* Informations du trajet */}
|
||||
{routeInfo && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-lgreen/10 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-lgreen" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Distance</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{formatDistance(routeInfo.distance)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-l border-gray-200 pl-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-lblue/10 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Temps estimé</div>
|
||||
<div className="text-xl font-semibold text-gray-900">{formatDuration(routeInfo.duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carte */}
|
||||
<div className="flex-1 rounded-xl overflow-hidden border-2 border-gray-300 shadow-2xl" style={{ minHeight: '500px' }}>
|
||||
<MapContainer
|
||||
center={[centerLat, centerLng]}
|
||||
zoom={optimalZoom}
|
||||
style={{ height: '100%', width: '100%', zIndex: 0 }}
|
||||
zoomControl={true}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker
|
||||
position={[departCoords.lat, departCoords.lng]}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-marker-depart',
|
||||
html: `
|
||||
<div style="
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
border: 4px solid white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
">
|
||||
<div style="
|
||||
transform: rotate(45deg);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
">A</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
popupAnchor: [0, -40],
|
||||
})}
|
||||
>
|
||||
<Popup className="custom-popup">
|
||||
<div className="text-sm p-2">
|
||||
<div className="font-bold text-lgreen mb-1 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-lgreen"></div>
|
||||
Départ
|
||||
</div>
|
||||
<div className="text-gray-700 font-medium">{adresseDepart}</div>
|
||||
{adherentNom && <div className="text-gray-500 mt-1 text-xs">{adherentNom}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
<Marker
|
||||
position={[arriveeCoords.lat, arriveeCoords.lng]}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-marker-arrivee',
|
||||
html: `
|
||||
<div style="
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
border: 4px solid white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
">
|
||||
<div style="
|
||||
transform: rotate(45deg);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
">B</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
popupAnchor: [0, -40],
|
||||
})}
|
||||
>
|
||||
<Popup className="custom-popup">
|
||||
<div className="text-sm p-2">
|
||||
<div className="font-bold text-lblue mb-1 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-lblue"></div>
|
||||
Arrivée
|
||||
</div>
|
||||
<div className="text-gray-700 font-medium">{adresseArrivee}</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
<Polyline
|
||||
positions={[
|
||||
[departCoords.lat, departCoords.lng],
|
||||
[arriveeCoords.lat, arriveeCoords.lng],
|
||||
]}
|
||||
pathOptions={{
|
||||
color: '#6B46C1',
|
||||
weight: 5,
|
||||
opacity: 0.8,
|
||||
dashArray: '10, 10',
|
||||
}}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user