feat: Vigilance API officielle + tab 1 an + logo SVG + map à 5xl
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
1. lib/vigilance.ts : provider Météo France officielle via DPVigilance/v1
/cartevigilance/encours. Map vers VigilanceAlert (1 par dept × phen × J/J1)
en prenant phenomenon_max_color_id (pire de la journée). Filtre les domains
non-dept (FRA national, sub-côtes XX10, etc).
- Auto-pick : MF si key dispo, sinon opendatasoft. Override VIGILANCE_PROVIDER.
- Fallback opendatasoft si MF échoue (cache key différent pour pas polluer).
- Devrait fixer le lag de plusieurs heures observé sur Opendatasoft.
2. lib/climato.ts : fetch latest + previous (~4 MB compressé), garder 365j max
en cache 24h. Permet l'onglet "1 an" sur la page dept.
3. TemperatureChartInteractive : onglet "1 an" (apparaît si > 30j dispos),
série days365 + normales365 sérialisés au SSR.
4. Base.astro : logo header et footer utilisent <img src="/favicon.svg">
au lieu d'un emoji 🌡️.
5. index.astro : wrapper map repassé à container-tight (max-w-5xl) — la version
1400px était trop grande, on revient à la largeur du reste du site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb8d111a12
commit
5f8b765d79
7 changed files with 189 additions and 63 deletions
|
|
@ -5,7 +5,9 @@ PUBLIC_SITE_URL=https://info-canicule.nocleus.com
|
||||||
# Valkey shared infra — DB 0 (isolation par préfixe `info-canicule:*` via ACL)
|
# Valkey shared infra — DB 0 (isolation par préfixe `info-canicule:*` via ACL)
|
||||||
REDIS_URL=redis://info-canicule:{{ pass://Infra/Valkey — info-canicule/password }}@valkey:6379/0
|
REDIS_URL=redis://info-canicule:{{ pass://Infra/Valkey — info-canicule/password }}@valkey:6379/0
|
||||||
|
|
||||||
VIGILANCE_PROVIDER=opendatasoft
|
# Provider Vigilance : `meteofrance` (API officielle, frais) ou `opendatasoft` (no-auth, lag possible).
|
||||||
|
# Vide = auto (MF si METEOFRANCE_API_KEY défini, sinon opendatasoft).
|
||||||
|
VIGILANCE_PROVIDER=
|
||||||
VIGILANCE_CACHE_TTL=900
|
VIGILANCE_CACHE_TTL=900
|
||||||
|
|
||||||
# Umami analytics (RGPD, auto-hébergé — analytics.nocleus.com)
|
# Umami analytics (RGPD, auto-hébergé — analytics.nocleus.com)
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,20 @@ interface Props {
|
||||||
hourly?: HourlySeries | null; // 24h
|
hourly?: HourlySeries | null; // 24h
|
||||||
days7: DayObservation[];
|
days7: DayObservation[];
|
||||||
days30: DayObservation[];
|
days30: DayObservation[];
|
||||||
normales7?: NormalePoint[]; // 1 normale par jour (même longueur que days7)
|
days365: DayObservation[];
|
||||||
normales30?: NormalePoint[]; // idem pour 30j
|
normales7?: NormalePoint[];
|
||||||
normaleHourly?: NormalePoint | null; // 1 seule normale (jour courant) pour overlay 24h
|
normales30?: NormalePoint[];
|
||||||
|
normales365?: NormalePoint[];
|
||||||
|
normaleHourly?: NormalePoint | null; // 1 normale (jour courant) pour overlay 24h
|
||||||
stationLabel?: string | null;
|
stationLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hourly, days7, days30, normales7 = [], normales30 = [], normaleHourly = null, stationLabel } = Astro.props;
|
const {
|
||||||
|
hourly, days7, days30, days365,
|
||||||
|
normales7 = [], normales30 = [], normales365 = [],
|
||||||
|
normaleHourly = null, stationLabel,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
// Sérialiser les 3 séries pour le JS client (toggle + hover).
|
|
||||||
// Pour les normales :
|
|
||||||
// - 24h : 1 seul point répété (la normale du jour courant) → ligne horizontale
|
|
||||||
// - 7j / 30j : 1 normale par jour (courbe qui suit la saison)
|
|
||||||
const serialize = {
|
const serialize = {
|
||||||
hourly: hourly?.observations.map((o) => ({ t: o.time, tx: o.t, tn: null })) ?? [],
|
hourly: hourly?.observations.map((o) => ({ t: o.time, tx: o.t, tn: null })) ?? [],
|
||||||
days7: days7.map((d, i) => ({
|
days7: days7.map((d, i) => ({
|
||||||
|
|
@ -29,10 +31,15 @@ const serialize = {
|
||||||
t: d.date, tx: d.tx, tn: d.tn,
|
t: d.date, tx: d.tx, tn: d.tn,
|
||||||
normTx: normales30[i]?.tx ?? null, normTn: normales30[i]?.tn ?? null,
|
normTx: normales30[i]?.tx ?? null, normTn: normales30[i]?.tn ?? null,
|
||||||
})),
|
})),
|
||||||
|
days365: days365.map((d, i) => ({
|
||||||
|
t: d.date, tx: d.tx, tn: d.tn,
|
||||||
|
normTx: normales365[i]?.tx ?? null, normTn: normales365[i]?.tn ?? null,
|
||||||
|
})),
|
||||||
normaleHourly: normaleHourly ? { tx: normaleHourly.tx, tn: normaleHourly.tn } : null,
|
normaleHourly: normaleHourly ? { tx: normaleHourly.tx, tn: normaleHourly.tn } : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasHourly = (hourly?.observations.length ?? 0) > 0;
|
const hasHourly = (hourly?.observations.length ?? 0) > 0;
|
||||||
|
const hasYear = days365.length > 30;
|
||||||
const defaultPeriod = hasHourly ? '24h' : '7j';
|
const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -68,10 +75,21 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
role="tab"
|
role="tab"
|
||||||
data-period="30j"
|
data-period="30j"
|
||||||
aria-selected={false}
|
aria-selected={false}
|
||||||
class="period-tab px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white"
|
class:list={['period-tab px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white', hasYear ? 'border-r border-slate-200' : '']}
|
||||||
>
|
>
|
||||||
30 jours
|
30 jours
|
||||||
</button>
|
</button>
|
||||||
|
{hasYear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
data-period="1an"
|
||||||
|
aria-selected={false}
|
||||||
|
class="period-tab px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white"
|
||||||
|
>
|
||||||
|
1 an
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -117,7 +135,11 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(period) {
|
function render(period) {
|
||||||
const series = data[period === '24h' ? 'hourly' : period === '7j' ? 'days7' : 'days30'];
|
const seriesKey = period === '24h' ? 'hourly'
|
||||||
|
: period === '7j' ? 'days7'
|
||||||
|
: period === '30j' ? 'days30'
|
||||||
|
: 'days365';
|
||||||
|
const series = data[seriesKey];
|
||||||
if (!series || series.length === 0) {
|
if (!series || series.length === 0) {
|
||||||
svg.innerHTML = '<text x="400" y="140" text-anchor="middle" fill="#94a3b8">Aucune donnée disponible</text>';
|
svg.innerHTML = '<text x="400" y="140" text-anchor="middle" fill="#94a3b8">Aucune donnée disponible</text>';
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ const jsonLd = {
|
||||||
<header class="border-b border-slate-200 bg-white">
|
<header class="border-b border-slate-200 bg-white">
|
||||||
<div class="container-tight flex flex-wrap items-center justify-between gap-3 py-4">
|
<div class="container-tight flex flex-wrap items-center justify-between gap-3 py-4">
|
||||||
<a href="/" class="flex items-center gap-2 no-underline">
|
<a href="/" class="flex items-center gap-2 no-underline">
|
||||||
<span class="text-2xl">🌡️</span>
|
<img src="/favicon.svg" alt="" width="32" height="32" class="h-8 w-8" />
|
||||||
<span class="text-lg font-bold text-canicule-700">Info Canicule</span>
|
<span class="text-lg font-bold text-canicule-700">Info Canicule</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm font-medium text-slate-600">
|
<nav class="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm font-medium text-slate-600">
|
||||||
|
|
@ -121,7 +121,10 @@ const jsonLd = {
|
||||||
<div class="container-tight py-6 text-sm text-slate-500">
|
<div class="container-tight py-6 text-sm text-slate-500">
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="/favicon.svg" alt="" width="28" height="28" class="h-7 w-7" />
|
||||||
<p class="font-semibold text-slate-700">Info Canicule</p>
|
<p class="font-semibold text-slate-700">Info Canicule</p>
|
||||||
|
</div>
|
||||||
<p class="mt-1 text-xs">
|
<p class="mt-1 text-xs">
|
||||||
Service d'information publique gratuit, sans publicité, non lucratif.
|
Service d'information publique gratuit, sans publicité, non lucratif.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ import { cacheOrFetch } from './cache';
|
||||||
|
|
||||||
const BASE = 'https://object.files.data.gouv.fr/meteofrance/data/synchro_ftp/BASE/QUOT';
|
const BASE = 'https://object.files.data.gouv.fr/meteofrance/data/synchro_ftp/BASE/QUOT';
|
||||||
|
|
||||||
// Format des fichiers : Q_<DEPT>_latest-2025-2026_RR-T-Vent.csv.gz
|
// Format des fichiers : Q_<DEPT>_<PERIOD>_RR-T-Vent.csv.gz
|
||||||
// Colonnes utiles : NUM_POSTE, AAAAMMJJ, RR (mm), TN (°C), TX (°C), TM (°C).
|
// PERIOD = "latest-2025-2026" (année courante, ~150j en mai)
|
||||||
// Délimiteur : ';'. Valeurs manquantes : vide.
|
// | "previous-1950-2024" (historique long, ~70 ans, ~4 MB compressé)
|
||||||
// Une seule période "latest" couvre l'année courante.
|
// Colonnes utiles : NUM_POSTE, AAAAMMJJ, RR (mm), TN/TX/TM (°C), agrégées par dept.
|
||||||
|
// Cache 24h — les données du jour J arrivent à J+1.
|
||||||
|
|
||||||
export interface DayObservation {
|
export interface DayObservation {
|
||||||
date: string; // YYYY-MM-DD
|
date: string;
|
||||||
tn: number | null;
|
tn: number | null;
|
||||||
tx: number | null;
|
tx: number | null;
|
||||||
tm: number | null;
|
tm: number | null;
|
||||||
|
|
@ -20,24 +21,27 @@ export interface DayObservation {
|
||||||
export interface ClimatoSeries {
|
export interface ClimatoSeries {
|
||||||
dept: string;
|
dept: string;
|
||||||
fetchedAt: string;
|
fetchedAt: string;
|
||||||
days: DayObservation[]; // 30 derniers jours, sorted asc
|
days: DayObservation[]; // 365 derniers jours, sorted asc
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIOD = 'latest-2025-2026';
|
const LATEST = 'latest-2025-2026';
|
||||||
|
const PREVIOUS = 'previous-1950-2024';
|
||||||
|
const MAX_DAYS = 365;
|
||||||
|
|
||||||
// Mapping département front → fichier (Andorre 99 et DROM pas dans la base classique).
|
function buildUrl(dept: string, period: string): string | null {
|
||||||
function buildUrl(dept: string): string | null {
|
|
||||||
if (dept === '99') return null;
|
if (dept === '99') return null;
|
||||||
if (dept === '2A' || dept === '2B') {
|
// Corse historique = code 20 (avant split 2A/2B en 1976)
|
||||||
// Corse a son propre fichier 2A/2B selon le dataset.
|
if ((dept === '2A' || dept === '2B') && period === PREVIOUS) {
|
||||||
return `${BASE}/Q_${dept}_${PERIOD}_RR-T-Vent.csv.gz`;
|
return `${BASE}/Q_20_${period}_RR-T-Vent.csv.gz`;
|
||||||
}
|
}
|
||||||
return `${BASE}/Q_${dept}_${PERIOD}_RR-T-Vent.csv.gz`;
|
return `${BASE}/Q_${dept}_${period}_RR-T-Vent.csv.gz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCsv(text: string): ClimatoSeries['days'] {
|
type Agg = { tnSum: number; tnN: number; txSum: number; txN: number; tmSum: number; tmN: number; rrSum: number; rrN: number; stations: number };
|
||||||
|
|
||||||
|
function parseCsvInto(text: string, byDate: Map<string, Agg>): void {
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
if (lines.length < 2) return [];
|
if (lines.length < 2) return;
|
||||||
const header = lines[0].split(';');
|
const header = lines[0].split(';');
|
||||||
const idx = {
|
const idx = {
|
||||||
date: header.indexOf('AAAAMMJJ'),
|
date: header.indexOf('AAAAMMJJ'),
|
||||||
|
|
@ -46,11 +50,7 @@ function parseCsv(text: string): ClimatoSeries['days'] {
|
||||||
tm: header.indexOf('TM'),
|
tm: header.indexOf('TM'),
|
||||||
rr: header.indexOf('RR'),
|
rr: header.indexOf('RR'),
|
||||||
};
|
};
|
||||||
if (idx.date === -1) return [];
|
if (idx.date === -1) return;
|
||||||
|
|
||||||
// Aggregate by date across stations (mean of available values).
|
|
||||||
type Agg = { tnSum: number; tnN: number; txSum: number; txN: number; tmSum: number; tmN: number; rrSum: number; rrN: number; stations: number };
|
|
||||||
const byDate = new Map<string, Agg>();
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
|
@ -64,18 +64,39 @@ function parseCsv(text: string): ClimatoSeries['days'] {
|
||||||
byDate.set(date, agg);
|
byDate.set(date, agg);
|
||||||
}
|
}
|
||||||
agg.stations++;
|
agg.stations++;
|
||||||
const addNum = (raw: string | undefined, sum: 'tnSum' | 'txSum' | 'tmSum' | 'rrSum', n: 'tnN' | 'txN' | 'tmN' | 'rrN') => {
|
const add = (raw: string | undefined, sum: 'tnSum' | 'txSum' | 'tmSum' | 'rrSum', n: 'tnN' | 'txN' | 'tmN' | 'rrN') => {
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const v = parseFloat(raw.replace(',', '.'));
|
const v = parseFloat(raw.replace(',', '.'));
|
||||||
if (Number.isFinite(v)) {
|
if (Number.isFinite(v)) { agg![sum] += v; agg![n]++; }
|
||||||
agg![sum] += v;
|
|
||||||
agg![n]++;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if (idx.tn !== -1) addNum(cols[idx.tn], 'tnSum', 'tnN');
|
if (idx.tn !== -1) add(cols[idx.tn], 'tnSum', 'tnN');
|
||||||
if (idx.tx !== -1) addNum(cols[idx.tx], 'txSum', 'txN');
|
if (idx.tx !== -1) add(cols[idx.tx], 'txSum', 'txN');
|
||||||
if (idx.tm !== -1) addNum(cols[idx.tm], 'tmSum', 'tmN');
|
if (idx.tm !== -1) add(cols[idx.tm], 'tmSum', 'tmN');
|
||||||
if (idx.rr !== -1) addNum(cols[idx.rr], 'rrSum', 'rrN');
|
if (idx.rr !== -1) add(cols[idx.rr], 'rrSum', 'rrN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOne(url: string): Promise<string | null> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
return gunzipSync(buf).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClimato(dept: string): Promise<ClimatoSeries> {
|
||||||
|
const byDate = new Map<string, Agg>();
|
||||||
|
// 1) latest (rapide, ~50 KB compressé) — couvre l'année courante
|
||||||
|
const latestUrl = buildUrl(dept, LATEST);
|
||||||
|
if (latestUrl) {
|
||||||
|
const text = await fetchOne(latestUrl);
|
||||||
|
if (text) parseCsvInto(text, byDate);
|
||||||
|
}
|
||||||
|
// 2) previous (~4 MB compressé) — nécessaire pour combler les jours hors année courante
|
||||||
|
// On le fetch toujours, son taille est tolérable au cold-fetch (cache 24h).
|
||||||
|
const previousUrl = buildUrl(dept, PREVIOUS);
|
||||||
|
if (previousUrl) {
|
||||||
|
const text = await fetchOne(previousUrl);
|
||||||
|
if (text) parseCsvInto(text, byDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const days: DayObservation[] = [...byDate.entries()]
|
const days: DayObservation[] = [...byDate.entries()]
|
||||||
|
|
@ -89,27 +110,11 @@ function parseCsv(text: string): ClimatoSeries['days'] {
|
||||||
stations: agg.stations,
|
stations: agg.stations,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Garder les 30 derniers jours.
|
// Garder MAX_DAYS derniers jours (365)
|
||||||
return days.slice(-30);
|
return { dept, fetchedAt: new Date().toISOString(), days: days.slice(-MAX_DAYS) };
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchClimato(dept: string): Promise<ClimatoSeries> {
|
|
||||||
const url = buildUrl(dept);
|
|
||||||
if (!url) {
|
|
||||||
return { dept, fetchedAt: new Date().toISOString(), days: [] };
|
|
||||||
}
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`climato fetch ${dept} failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
|
||||||
const text = gunzipSync(buf).toString('utf-8');
|
|
||||||
const days = parseCsv(text);
|
|
||||||
return { dept, fetchedAt: new Date().toISOString(), days };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClimatoForDepartement(dept: string): Promise<ClimatoSeries> {
|
export async function getClimatoForDepartement(dept: string): Promise<ClimatoSeries> {
|
||||||
// Cache 24h — les données journalières arrivent en J+1.
|
|
||||||
const ttl = 24 * 60 * 60;
|
const ttl = 24 * 60 * 60;
|
||||||
return cacheOrFetch(`climato:${dept}`, ttl, () => fetchClimato(dept));
|
return cacheOrFetch(`climato:${dept}`, ttl, () => fetchClimato(dept));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { cacheOrFetch } from './cache';
|
import { cacheOrFetch } from './cache';
|
||||||
import type { ColorId, PhenomenonId } from './phenomena';
|
import type { ColorId, PhenomenonId } from './phenomena';
|
||||||
import { COLORS, PHENOMENA } from './phenomena';
|
import { COLORS, PHENOMENA } from './phenomena';
|
||||||
|
import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth';
|
||||||
|
|
||||||
export interface VigilanceAlert {
|
export interface VigilanceAlert {
|
||||||
departement: string;
|
departement: string;
|
||||||
|
|
@ -76,9 +77,97 @@ async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Provider : API Météo France officielle (DPVigilance/v1) ---
|
||||||
|
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
||||||
|
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
|
||||||
|
async function fetchMeteoFrance(): Promise<VigilanceSnapshot> {
|
||||||
|
const res = await fetchMF('/public/DPVigilance/v1/cartevigilance/encours');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`MF cartevigilance failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
product: {
|
||||||
|
update_time: string;
|
||||||
|
periods: Array<{
|
||||||
|
echeance: 'J' | 'J1';
|
||||||
|
begin_validity_time?: string;
|
||||||
|
end_validity_time?: string;
|
||||||
|
timelaps: {
|
||||||
|
domain_ids: Array<{
|
||||||
|
domain_id: string;
|
||||||
|
max_color_id: number;
|
||||||
|
phenomenon_items: Array<{
|
||||||
|
phenomenon_id: string;
|
||||||
|
phenomenon_max_color_id: number;
|
||||||
|
timelaps_items: Array<{ begin_time: string; end_time: string; color_id: number }>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const productDatetime = json.product.update_time;
|
||||||
|
const alerts: VigilanceAlert[] = [];
|
||||||
|
for (const period of json.product.periods ?? []) {
|
||||||
|
const echeance = period.echeance;
|
||||||
|
if (echeance !== 'J' && echeance !== 'J1') continue;
|
||||||
|
for (const dom of period.timelaps.domain_ids ?? []) {
|
||||||
|
// Filtre les domains : on garde dept métropole (2 chiffres ou 2A/2B) + DROM (3 chiffres 97x)
|
||||||
|
// On exclut 'FRA' (résumé national), sub-domains côte 'XX10', etc.
|
||||||
|
const dept = dom.domain_id;
|
||||||
|
if (!/^(\d{2}|2A|2B|97\d|98\d|99)$/.test(dept)) continue;
|
||||||
|
for (const phen of dom.phenomenon_items ?? []) {
|
||||||
|
const pid = parseInt(phen.phenomenon_id, 10);
|
||||||
|
if (!(pid in PHENOMENA)) continue;
|
||||||
|
const cid = phen.phenomenon_max_color_id;
|
||||||
|
if (!(cid in COLORS)) continue;
|
||||||
|
const tls = phen.timelaps_items ?? [];
|
||||||
|
// Pour les begin/end on prend l'enveloppe complète des timelaps de la journée
|
||||||
|
const begin = tls.length ? tls.map((t) => t.begin_time).sort()[0] : (period.begin_validity_time ?? productDatetime);
|
||||||
|
const end = tls.length ? tls.map((t) => t.end_time).sort().slice(-1)[0] : (period.end_validity_time ?? productDatetime);
|
||||||
|
alerts.push({
|
||||||
|
departement: dept,
|
||||||
|
echeance,
|
||||||
|
phenomenonId: pid as PhenomenonId,
|
||||||
|
colorId: cid as ColorId,
|
||||||
|
beginTime: begin,
|
||||||
|
endTime: end,
|
||||||
|
productDatetime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
productDatetime,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProvider(): { name: string; fn: () => Promise<VigilanceSnapshot> } {
|
||||||
|
const explicit = process.env.VIGILANCE_PROVIDER;
|
||||||
|
if (explicit === 'opendatasoft') return { name: 'opendatasoft', fn: fetchOpendatasoft };
|
||||||
|
if (explicit === 'meteofrance') return { name: 'meteofrance', fn: fetchMeteoFrance };
|
||||||
|
// Auto : MF si clé dispo (publication plus fraîche), sinon fallback Opendatasoft (no-auth)
|
||||||
|
if (hasMeteoFranceCredentials()) return { name: 'meteofrance', fn: fetchMeteoFrance };
|
||||||
|
return { name: 'opendatasoft', fn: fetchOpendatasoft };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
||||||
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
||||||
return cacheOrFetch(CACHE_KEY, ttl, fetchOpendatasoft);
|
const provider = pickProvider();
|
||||||
|
try {
|
||||||
|
return await cacheOrFetch(CACHE_KEY, ttl, provider.fn);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
||||||
|
if (provider.name === 'meteofrance') {
|
||||||
|
console.warn('[vigilance] MF failed, falling back to opendatasoft:', (e as Error).message);
|
||||||
|
return cacheOrFetch(CACHE_KEY + ':fallback', ttl, fetchOpendatasoft);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,16 @@ if (!drom) {
|
||||||
|
|
||||||
const last7 = climato?.days?.slice(-7) ?? [];
|
const last7 = climato?.days?.slice(-7) ?? [];
|
||||||
const last30 = climato?.days?.slice(-30) ?? [];
|
const last30 = climato?.days?.slice(-30) ?? [];
|
||||||
|
const last365 = climato?.days?.slice(-365) ?? [];
|
||||||
|
let normales365: Array<{ tx: number | null; tn: number | null }> = [];
|
||||||
|
|
||||||
if (!drom) {
|
if (!drom) {
|
||||||
const series7 = await normalesForRange(dept.code, last7.map((d) => d.date));
|
const series7 = await normalesForRange(dept.code, last7.map((d) => d.date));
|
||||||
normales7 = series7.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
|
normales7 = series7.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
|
||||||
const series30 = await normalesForRange(dept.code, last30.map((d) => d.date));
|
const series30 = await normalesForRange(dept.code, last30.map((d) => d.date));
|
||||||
normales30 = series30.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
|
normales30 = series30.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
|
||||||
// Normale "du jour courant" (pour overlay du graphe 24h, ligne horizontale)
|
const series365 = await normalesForRange(dept.code, last365.map((d) => d.date));
|
||||||
|
normales365 = series365.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
|
||||||
normaleHourly = await normaleForDate(dept.code, new Date());
|
normaleHourly = await normaleForDate(dept.code, new Date());
|
||||||
if (normaleHourly) normaleHourly = { tx: normaleHourly.tx, tn: normaleHourly.tn };
|
if (normaleHourly) normaleHourly = { tx: normaleHourly.tx, tn: normaleHourly.tn };
|
||||||
}
|
}
|
||||||
|
|
@ -187,8 +190,10 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
|
||||||
hourly={hourly}
|
hourly={hourly}
|
||||||
days7={last7}
|
days7={last7}
|
||||||
days30={last30}
|
days30={last30}
|
||||||
|
days365={last365}
|
||||||
normales7={normales7}
|
normales7={normales7}
|
||||||
normales30={normales30}
|
normales30={normales30}
|
||||||
|
normales365={normales365}
|
||||||
normaleHourly={normaleHourly}
|
normaleHourly={normaleHourly}
|
||||||
stationLabel={stationLabel}
|
stationLabel={stationLabel}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ const productDate = snapshot?.productDatetime
|
||||||
Survolez un département pour voir le détail des alertes, cliquez pour la page complète.
|
Survolez un département pour voir le détail des alertes, cliquez pour la page complète.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<div class="mx-auto w-full max-w-[1400px] px-4 pb-4 sm:px-6">
|
<div class="container-tight pb-4">
|
||||||
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
|
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
|
||||||
</div>
|
</div>
|
||||||
<section class="container-tight pb-8">
|
<section class="container-tight pb-8">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue