feat: Vigilance API officielle + tab 1 an + logo SVG + map à 5xl
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:
Florian 2026-05-26 01:57:35 +02:00
parent cb8d111a12
commit 5f8b765d79
7 changed files with 189 additions and 63 deletions

View file

@ -7,18 +7,20 @@ interface Props {
hourly?: HourlySeries | null; // 24h
days7: DayObservation[];
days30: DayObservation[];
normales7?: NormalePoint[]; // 1 normale par jour (même longueur que days7)
normales30?: NormalePoint[]; // idem pour 30j
normaleHourly?: NormalePoint | null; // 1 seule normale (jour courant) pour overlay 24h
days365: DayObservation[];
normales7?: NormalePoint[];
normales30?: NormalePoint[];
normales365?: NormalePoint[];
normaleHourly?: NormalePoint | null; // 1 normale (jour courant) pour overlay 24h
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 = {
hourly: hourly?.observations.map((o) => ({ t: o.time, tx: o.t, tn: null })) ?? [],
days7: days7.map((d, i) => ({
@ -29,10 +31,15 @@ const serialize = {
t: d.date, tx: d.tx, tn: d.tn,
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,
};
const hasHourly = (hourly?.observations.length ?? 0) > 0;
const hasYear = days365.length > 30;
const defaultPeriod = hasHourly ? '24h' : '7j';
---
@ -68,10 +75,21 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
role="tab"
data-period="30j"
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
</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>
@ -117,7 +135,11 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
}
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) {
svg.innerHTML = '<text x="400" y="140" text-anchor="middle" fill="#94a3b8">Aucune donnée disponible</text>';
return;

View file

@ -103,7 +103,7 @@ const jsonLd = {
<header class="border-b border-slate-200 bg-white">
<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">
<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>
</a>
<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="grid gap-4 sm:grid-cols-3">
<div>
<p class="font-semibold text-slate-700">Info Canicule</p>
<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>
</div>
<p class="mt-1 text-xs">
Service d'information publique gratuit, sans publicité, non lucratif.
</p>

View file

@ -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_<DEPT>_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_<DEPT>_<PERIOD>_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<string, Agg>): 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<string, Agg>();
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<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()]
@ -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<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 };
// Garder MAX_DAYS derniers jours (365)
return { dept, fetchedAt: new Date().toISOString(), days: days.slice(-MAX_DAYS) };
}
export async function getClimatoForDepartement(dept: string): Promise<ClimatoSeries> {
// Cache 24h — les données journalières arrivent en J+1.
const ttl = 24 * 60 * 60;
return cacheOrFetch(`climato:${dept}`, ttl, () => fetchClimato(dept));
}

View file

@ -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<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> {
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> {

View file

@ -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}
/>

View file

@ -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.
</p>
</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} />
</div>
<section class="container-tight pb-8">