Amélioration avec des fonctions d'un vrai dashboard automotive et containerisation du projet pour faciliter le déploiement
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Public prototype providers, replaceable with self-hosted instances.
|
||||
export class ServiceError extends Error {
|
||||
constructor(status, message) { super(message); this.status = status; }
|
||||
}
|
||||
function number(params, name, min, max) {
|
||||
const raw = params.get(name), n = raw?.trim() ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(n) || n < min || n > max) throw new ServiceError(400, `Coordonnée ${name} invalide`);
|
||||
return n;
|
||||
}
|
||||
export function createServices({ fetcher = fetch, now = Date.now, env = process.env } = {}) {
|
||||
const cache = new Map(), inflight = new Map(), last = new Map();
|
||||
const endpoints = {
|
||||
weather: env.WEATHER_URL || 'https://api.open-meteo.com/v1/forecast',
|
||||
search: env.GEOCODER_URL || 'https://photon.komoot.io/api/',
|
||||
route: env.ROUTER_URL || 'https://router.project-osrm.org/route/v1/driving/'
|
||||
};
|
||||
async function request(kind, url, ttl) {
|
||||
const key = url.href, saved = cache.get(key);
|
||||
if (saved && now() - saved.time < ttl) return saved.data;
|
||||
if (inflight.has(key)) return inflight.get(key);
|
||||
if (now() - (last.get(kind) ?? -Infinity) < 1100 || inflight.size >= 6) throw new ServiceError(429, 'Patientez un instant avant de réessayer.');
|
||||
last.set(kind, now());
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetcher(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'CARIA-Cockpit/0.2 (personal automotive interface)', Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Upstream');
|
||||
const data = await response.json();
|
||||
if (cache.size >= 128) cache.delete(cache.keys().next().value);
|
||||
cache.set(key, { time: now(), data }); return data;
|
||||
} catch { throw new ServiceError(502, 'Service externe indisponible. Réessayez avec une connexion Internet.'); }
|
||||
finally { inflight.delete(key); }
|
||||
})();
|
||||
inflight.set(key, promise); return promise;
|
||||
}
|
||||
return async (path, params) => {
|
||||
if (path === '/api/map-config') return {
|
||||
tileUrl: env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
tileAttribution: env.TILE_ATTRIBUTION || '© OpenStreetMap contributors'
|
||||
};
|
||||
if (path === '/api/weather') {
|
||||
const lat = number(params, 'lat', -90, 90), lon = number(params, 'lon', -180, 180);
|
||||
const url = new URL(endpoints.weather);
|
||||
url.search = new URLSearchParams({ latitude: lat.toFixed(2), longitude: lon.toFixed(2), current: 'temperature_2m,weather_code,wind_speed_10m', daily: 'temperature_2m_max,temperature_2m_min', timezone: 'auto', forecast_days: '1' });
|
||||
const d = await request('weather', url, 15 * 60000);
|
||||
if (![d.current?.temperature_2m, d.current?.wind_speed_10m, d.current?.weather_code, d.daily?.temperature_2m_min?.[0], d.daily?.temperature_2m_max?.[0]].every(Number.isFinite)) throw new ServiceError(502, 'Réponse météo incomplète');
|
||||
return { source: 'Open-Meteo', fetchedAt: now(), observedAt: d.current.time, temperature: d.current.temperature_2m, code: d.current.weather_code, wind: d.current.wind_speed_10m, min: d.daily.temperature_2m_min[0], max: d.daily.temperature_2m_max[0] };
|
||||
}
|
||||
if (path === '/api/search') {
|
||||
const q = params.get('q')?.trim();
|
||||
if (!q || q.length < 3 || q.length > 180) throw new ServiceError(400, 'Saisissez entre 3 et 180 caractères.');
|
||||
const url = new URL(endpoints.search); url.search = new URLSearchParams({ q, lang: 'fr', limit: '5' });
|
||||
const d = await request('search', url, 24 * 3600000);
|
||||
if (!Array.isArray(d.features)) throw new ServiceError(502, 'Réponse de recherche invalide');
|
||||
return d.features.slice(0, 5).filter(f => f.geometry?.type === 'Point' && f.geometry.coordinates?.length === 2 && f.geometry.coordinates.every(Number.isFinite)).map(f => ({
|
||||
lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1],
|
||||
label: [...new Set([f.properties?.name, f.properties?.street, f.properties?.housenumber, f.properties?.postcode, f.properties?.city, f.properties?.country].filter(Boolean))].join(', ').slice(0, 300)
|
||||
}));
|
||||
}
|
||||
if (path === '/api/route') {
|
||||
const fromLat = number(params, 'fromLat', -85, 85), fromLon = number(params, 'fromLon', -180, 180);
|
||||
const toLat = number(params, 'toLat', -85, 85), toLon = number(params, 'toLon', -180, 180);
|
||||
const url = new URL(`${endpoints.route.replace(/\/$/, '')}/${fromLon},${fromLat};${toLon},${toLat}`);
|
||||
url.search = new URLSearchParams({ overview: 'full', geometries: 'geojson', steps: 'true' });
|
||||
const d = await request('route', url, 60000), route = d.routes?.[0];
|
||||
if (d.code !== 'Ok' || !route || route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates) || route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000 || route.geometry.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite)) || !Number.isFinite(route.distance) || !Number.isFinite(route.duration) || !Array.isArray(route.legs)) throw new ServiceError(422, 'Aucun itinéraire routier trouvé.');
|
||||
const steps = route.legs.flatMap(leg => leg.steps || []);
|
||||
if (!steps.length || steps.some(s => !s.maneuver || !Number.isFinite(s.distance) || !Number.isFinite(s.duration))) throw new ServiceError(502, 'Étapes de navigation incomplètes');
|
||||
return { source: 'OSRM', distance: route.distance, duration: route.duration, geometry: route.geometry, steps: steps.map(s => ({ distance: s.distance, duration: s.duration, name: s.name, maneuver: s.maneuver })) };
|
||||
}
|
||||
throw new ServiceError(404, 'Introuvable');
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user