export function distance(a, b) { const rad = Math.PI / 180, lat = (b.lat - a.lat) * rad, lon = (b.lon - a.lon) * rad; const h = Math.sin(lat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(lon / 2) ** 2; return 6371000 * 2 * Math.asin(Math.min(1, Math.sqrt(h))); } export function validateZones(data) { if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.length > 1000) throw new Error('Fichier GeoJSON attendu, 1 000 zones maximum.'); const ids = new Set(); return data.features.map((f, i) => { const p = f.properties || {}, c = f.geometry?.coordinates; if (f.geometry?.type !== 'Point' || !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite) || Math.abs(c[0]) > 180 || Math.abs(c[1]) > 85 || !Number.isFinite(p.radiusM) || p.radiusM < 300 || p.radiusM > 10000 || !/^[A-Z]{2}$/.test(p.country || '') || typeof p.source !== 'string' || !p.source.trim() || !Number.isFinite(Date.parse(p.updatedAt)) || !Number.isFinite(Date.parse(p.expiresAt)) || Date.parse(p.expiresAt) <= Date.parse(p.updatedAt)) throw new Error(`Zone ${i + 1} invalide : coordonnées, pays, rayon (300–10 000 m), source et dates requis.`); const id = String(p.id ?? i).slice(0, 100); if (ids.has(id)) throw new Error('Identifiants de zones en double.'); ids.add(id); return { id, lat: c[1], lon: c[0], radiusM: p.radiusM, country: p.country, name: String(p.name || 'Zone de vigilance').slice(0, 120), source: p.source.slice(0, 150), updatedAt: p.updatedAt, expiresAt: p.expiresAt }; }); } export function nearbyZones(zones, position, country, now = Date.now()) { if (!position || position.source !== 'gps' || !Number.isFinite(position.accuracy) || position.accuracy > 100 || !Number.isFinite(position.timestamp) || Math.abs(now - position.timestamp) > 15000) return []; return zones.filter(z => z.country === country && Date.parse(z.updatedAt) <= now && Date.parse(z.expiresAt) > now) .map(z => ({ ...z, remaining: Math.max(0, distance(position, z) - z.radiusM) })) .filter(z => z.remaining <= 400).sort((a, b) => a.remaining - b.remaining); } export function instruction(step) { const m = step.maneuver || {}, ways = { left: 'à gauche', right: 'à droite', 'slight left': 'légèrement à gauche', 'slight right': 'légèrement à droite', 'sharp left': 'franchement à gauche', 'sharp right': 'franchement à droite', straight: 'tout droit', uturn: 'en faisant demi-tour' }; if (m.type === 'arrive') return 'Vous arrivez à destination'; if (m.type === 'depart') return `Départ${step.name ? ` sur ${step.name}` : ''}`; if (['roundabout', 'rotary'].includes(m.type)) return `Au rond-point, ${m.exit ? `prenez la sortie ${m.exit}` : 'suivez l’itinéraire'}`; return `${m.type === 'merge' ? 'Insérez-vous' : m.type === 'fork' ? 'Restez' : 'Continuez'} ${ways[m.modifier] || 'sur l’itinéraire'}${step.name ? ` sur ${step.name}` : ''}`; } // Nearest segment projection gives progress even on long rural road segments. export function routeProgress(coords, position) { let best = { away: Infinity, travelled: 0 }, total = 0; const scale = Math.cos(position.lat * Math.PI / 180); for (let i = 1; i < coords.length; i++) { const a = { lon: coords[i - 1][0], lat: coords[i - 1][1] }, b = { lon: coords[i][0], lat: coords[i][1] }; const dx = (b.lon - a.lon) * scale, dy = b.lat - a.lat; const fraction = Math.max(0, Math.min(1, (((position.lon - a.lon) * scale) * dx + (position.lat - a.lat) * dy) / (dx * dx + dy * dy || 1))); const point = { lon: a.lon + fraction * (b.lon - a.lon), lat: a.lat + fraction * (b.lat - a.lat) }; const length = distance(a, b), away = distance(position, point); if (away < best.away) best = { away, travelled: total + fraction * length }; total += length; } return { ...best, total, fraction: total ? best.travelled / total : 0 }; }