-
Info Canicule
+
+

+
Info Canicule
+
Service d'information publique gratuit, sans publicité, non lucratif.
diff --git a/src/lib/climato.ts b/src/lib/climato.ts
index 8c8198d..7ec76a8 100644
--- a/src/lib/climato.ts
+++ b/src/lib/climato.ts
@@ -3,13 +3,14 @@ import { cacheOrFetch } from './cache';
const BASE = 'https://object.files.data.gouv.fr/meteofrance/data/synchro_ftp/BASE/QUOT';
-// Format des fichiers : Q_
_latest-2025-2026_RR-T-Vent.csv.gz
-// Colonnes utiles : NUM_POSTE, AAAAMMJJ, RR (mm), TN (°C), TX (°C), TM (°C).
-// Délimiteur : ';'. Valeurs manquantes : vide.
-// Une seule période "latest" couvre l'année courante.
+// Format des fichiers : Q___RR-T-Vent.csv.gz
+// PERIOD = "latest-2025-2026" (année courante, ~150j en mai)
+// | "previous-1950-2024" (historique long, ~70 ans, ~4 MB compressé)
+// 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 {
- date: string; // YYYY-MM-DD
+ date: string;
tn: number | null;
tx: number | null;
tm: number | null;
@@ -20,24 +21,27 @@ export interface DayObservation {
export interface ClimatoSeries {
dept: 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): string | null {
+function buildUrl(dept: string, period: string): string | null {
if (dept === '99') return null;
- if (dept === '2A' || dept === '2B') {
- // Corse a son propre fichier 2A/2B selon le dataset.
- return `${BASE}/Q_${dept}_${PERIOD}_RR-T-Vent.csv.gz`;
+ // Corse historique = code 20 (avant split 2A/2B en 1976)
+ if ((dept === '2A' || dept === '2B') && period === PREVIOUS) {
+ 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): void {
const lines = text.split(/\r?\n/);
- if (lines.length < 2) return [];
+ if (lines.length < 2) return;
const header = lines[0].split(';');
const idx = {
date: header.indexOf('AAAAMMJJ'),
@@ -46,11 +50,7 @@ function parseCsv(text: string): ClimatoSeries['days'] {
tm: header.indexOf('TM'),
rr: header.indexOf('RR'),
};
- 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();
+ if (idx.date === -1) return;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
@@ -64,18 +64,39 @@ function parseCsv(text: string): ClimatoSeries['days'] {
byDate.set(date, agg);
}
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;
const v = parseFloat(raw.replace(',', '.'));
- if (Number.isFinite(v)) {
- agg![sum] += v;
- agg![n]++;
- }
+ if (Number.isFinite(v)) { agg![sum] += v; agg![n]++; }
};
- if (idx.tn !== -1) addNum(cols[idx.tn], 'tnSum', 'tnN');
- if (idx.tx !== -1) addNum(cols[idx.tx], 'txSum', 'txN');
- if (idx.tm !== -1) addNum(cols[idx.tm], 'tmSum', 'tmN');
- if (idx.rr !== -1) addNum(cols[idx.rr], 'rrSum', 'rrN');
+ if (idx.tn !== -1) add(cols[idx.tn], 'tnSum', 'tnN');
+ if (idx.tx !== -1) add(cols[idx.tx], 'txSum', 'txN');
+ if (idx.tm !== -1) add(cols[idx.tm], 'tmSum', 'tmN');
+ if (idx.rr !== -1) add(cols[idx.rr], 'rrSum', 'rrN');
+ }
+}
+
+async function fetchOne(url: string): Promise {
+ 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 {
+ const byDate = new Map();
+ // 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()]
@@ -89,27 +110,11 @@ function parseCsv(text: string): ClimatoSeries['days'] {
stations: agg.stations,
}));
- // Garder les 30 derniers jours.
- return days.slice(-30);
-}
-
-async function fetchClimato(dept: string): Promise {
- 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 };
+ // Garder MAX_DAYS derniers jours (365)
+ return { dept, fetchedAt: new Date().toISOString(), days: days.slice(-MAX_DAYS) };
}
export async function getClimatoForDepartement(dept: string): Promise {
- // Cache 24h — les données journalières arrivent en J+1.
const ttl = 24 * 60 * 60;
return cacheOrFetch(`climato:${dept}`, ttl, () => fetchClimato(dept));
}
diff --git a/src/lib/vigilance.ts b/src/lib/vigilance.ts
index a850c3b..cfe321f 100644
--- a/src/lib/vigilance.ts
+++ b/src/lib/vigilance.ts
@@ -1,6 +1,7 @@
import { cacheOrFetch } from './cache';
import type { ColorId, PhenomenonId } from './phenomena';
import { COLORS, PHENOMENA } from './phenomena';
+import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth';
export interface VigilanceAlert {
departement: string;
@@ -76,9 +77,97 @@ async function fetchOpendatasoft(): Promise {
};
}
+// --- 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 {
+ 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 } {
+ 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 {
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 {
diff --git a/src/pages/departement/[code].astro b/src/pages/departement/[code].astro
index 5e396a8..bfe2626 100644
--- a/src/pages/departement/[code].astro
+++ b/src/pages/departement/[code].astro
@@ -57,13 +57,16 @@ if (!drom) {
const last7 = climato?.days?.slice(-7) ?? [];
const last30 = climato?.days?.slice(-30) ?? [];
+const last365 = climato?.days?.slice(-365) ?? [];
+let normales365: Array<{ tx: number | null; tn: number | null }> = [];
if (!drom) {
const series7 = await normalesForRange(dept.code, last7.map((d) => d.date));
normales7 = series7.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
const series30 = await normalesForRange(dept.code, last30.map((d) => d.date));
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());
if (normaleHourly) normaleHourly = { tx: normaleHourly.tx, tn: normaleHourly.tn };
}
@@ -187,8 +190,10 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
hourly={hourly}
days7={last7}
days30={last30}
+ days365={last365}
normales7={normales7}
normales30={normales30}
+ normales365={normales365}
normaleHourly={normaleHourly}
stationLabel={stationLabel}
/>
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 62c22cb..7f10221 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -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.
-