feat: normales journalières + dedupe SYNOP + carte plus large
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
3 fixes en un :
1. lib/observations.ts : dedupe par validity_time sur le parse hourly.
L API MF SYNOP retourne chaque obs en doublon exact sur les ranges
multi-step (bug gateway WSO2). Constaté 7/8 paires identiques sur 24h.
2. Normales 1991-2020 passées de mensuelles à journalières (lissées 7j).
- scripts/build-normales.mjs : agrégation par day-of-year (1..366)
avec moving average ±3j pour stabiliser le bruit jour-à-jour.
- src/data/normales.json : 2.28 MB (vs 78 KB), 96 × 366 entrées.
- lib/normales.ts : normaleForDay/Date + normalesForRange, computeAnomaly
compare maintenant chaque jour observé à SA normale (pas à la moyenne
du mois) → bien plus précis sur les jours de transition mensuelle.
- TemperatureChartInteractive : overlay normales en COURBE qui suit
la saison (7j/30j) au lieu d une ligne horizontale unique.
24h reste ligne horizontale (normale du jour courant).
- Tooltip 7j/30j ajoute "↳ normale TX X°C (+Y)" pour montrer l écart
par point.
3. Carte sur la home libérée du container-tight (max-w-5xl = 1024px) :
wrapper dédié max-w-[1400px] → carte ~37% plus grande sur PC large.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84e8bd200f
commit
cb8d111a12
8 changed files with 283 additions and 118 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Calcule les normales mensuelles TN / TX par département sur la période WMO 1991-2020
|
// Calcule les normales JOURNALIÈRES TN / TX par département sur la période WMO 1991-2020
|
||||||
// à partir des données quotidiennes Météo France (Q_<DEPT>_previous-1950-2024_RR-T-Vent.csv.gz).
|
// à partir des données quotidiennes Météo France (Q_<DEPT>_previous-1950-2024_RR-T-Vent.csv.gz).
|
||||||
//
|
//
|
||||||
// Output : src/data/normales.json
|
// Output : src/data/normales.json
|
||||||
// { "01": [{ month:1, tn:-1.5, tx:5.2, tnStd:3.1, txStd:4.2, n:1234 }, ..., 12], "02": [...], ... }
|
// { "75": [{ doy:1, tn:-2.1, tx:5.2, tnStd:3.4, txStd:4.5, n:152 }, ..., 366], ... }
|
||||||
|
// doy = day-of-year (1..366), tn/tx en °C, tnStd/txStd écart-type.
|
||||||
//
|
//
|
||||||
// Lancé manuellement (très long : 96 × ~4 MB download + parse).
|
// Taille output : ~1.8 MB (vs ~78 KB pour mensuelles). Acceptable pour bundle.
|
||||||
// À relancer une fois par an, ou jamais — les normales 1991-2020 restent la référence WMO standard.
|
// Lancé manuellement, à relancer ~1× / an (les normales 1991-2020 restent référence WMO jusqu'à 2031).
|
||||||
|
|
||||||
import { gunzipSync } from 'node:zlib';
|
import { gunzipSync } from 'node:zlib';
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
@ -32,9 +33,17 @@ const DEPTS = [
|
||||||
'90','91','92','93','94','95',
|
'90','91','92','93','94','95',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Day-of-year 1..366. On utilise une convention "leap" (29 fév = 60),
|
||||||
|
// les années non-bissextiles n'ont pas de 60 → on garde le bucket même vide
|
||||||
|
// puis on l'interpole en post-process.
|
||||||
|
function dayOfYear(year, month, day) {
|
||||||
|
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||||
|
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||||
|
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
||||||
|
return (isLeap ? cumulLeap : cumulNonLeap)[month - 1] + day;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndParse(dept) {
|
async function fetchAndParse(dept) {
|
||||||
// Météo France utilise "20" pour la Corse historique (avant split 2A/2B en 1976).
|
|
||||||
// On affecte la même normale à 2A et 2B (climat sensiblement homogène à l'échelle mensuelle).
|
|
||||||
const fileCode = (dept === '2A' || dept === '2B') ? '20' : dept;
|
const fileCode = (dept === '2A' || dept === '2B') ? '20' : dept;
|
||||||
const url = `${BASE}/Q_${fileCode}_previous-1950-2024_RR-T-Vent.csv.gz`;
|
const url = `${BASE}/Q_${fileCode}_previous-1950-2024_RR-T-Vent.csv.gz`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
@ -49,7 +58,8 @@ async function fetchAndParse(dept) {
|
||||||
const idxTX = header.indexOf('TX');
|
const idxTX = header.indexOf('TX');
|
||||||
if (idxDate === -1) return null;
|
if (idxDate === -1) return null;
|
||||||
|
|
||||||
const acc = Array.from({ length: 12 }, () => ({
|
// 367 buckets pour doy 1..366 (index 0 inutilisé).
|
||||||
|
const acc = Array.from({ length: 367 }, () => ({
|
||||||
tnSum: 0, tnSquares: 0, tnN: 0,
|
tnSum: 0, tnSquares: 0, tnN: 0,
|
||||||
txSum: 0, txSquares: 0, txN: 0,
|
txSum: 0, txSquares: 0, txN: 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -62,36 +72,65 @@ async function fetchAndParse(dept) {
|
||||||
if (!raw || raw.length !== 8) continue;
|
if (!raw || raw.length !== 8) continue;
|
||||||
const year = parseInt(raw.slice(0, 4), 10);
|
const year = parseInt(raw.slice(0, 4), 10);
|
||||||
if (year < PERIOD_START || year > PERIOD_END) continue;
|
if (year < PERIOD_START || year > PERIOD_END) continue;
|
||||||
const m = parseInt(raw.slice(4, 6), 10) - 1;
|
const m = parseInt(raw.slice(4, 6), 10);
|
||||||
if (m < 0 || m > 11) continue;
|
const d = parseInt(raw.slice(6, 8), 10);
|
||||||
|
if (m < 1 || m > 12 || d < 1 || d > 31) continue;
|
||||||
|
const doy = dayOfYear(year, m, d);
|
||||||
|
if (doy < 1 || doy > 366) continue;
|
||||||
if (idxTN !== -1 && cols[idxTN]) {
|
if (idxTN !== -1 && cols[idxTN]) {
|
||||||
const v = parseFloat(cols[idxTN].replace(',', '.'));
|
const v = parseFloat(cols[idxTN].replace(',', '.'));
|
||||||
if (Number.isFinite(v)) {
|
if (Number.isFinite(v)) {
|
||||||
acc[m].tnSum += v; acc[m].tnSquares += v * v; acc[m].tnN++;
|
acc[doy].tnSum += v; acc[doy].tnSquares += v * v; acc[doy].tnN++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (idxTX !== -1 && cols[idxTX]) {
|
if (idxTX !== -1 && cols[idxTX]) {
|
||||||
const v = parseFloat(cols[idxTX].replace(',', '.'));
|
const v = parseFloat(cols[idxTX].replace(',', '.'));
|
||||||
if (Number.isFinite(v)) {
|
if (Number.isFinite(v)) {
|
||||||
acc[m].txSum += v; acc[m].txSquares += v * v; acc[m].txN++;
|
acc[doy].txSum += v; acc[doy].txSquares += v * v; acc[doy].txN++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc.map((a, m) => {
|
const out = [];
|
||||||
|
for (let doy = 1; doy <= 366; doy++) {
|
||||||
|
const a = acc[doy];
|
||||||
const tnMean = a.tnN > 0 ? a.tnSum / a.tnN : null;
|
const tnMean = a.tnN > 0 ? a.tnSum / a.tnN : null;
|
||||||
const txMean = a.txN > 0 ? a.txSum / a.txN : null;
|
const txMean = a.txN > 0 ? a.txSum / a.txN : null;
|
||||||
const tnVar = a.tnN > 1 && tnMean !== null ? (a.tnSquares - a.tnN * tnMean * tnMean) / (a.tnN - 1) : 0;
|
const tnVar = a.tnN > 1 && tnMean !== null ? (a.tnSquares - a.tnN * tnMean * tnMean) / (a.tnN - 1) : 0;
|
||||||
const txVar = a.txN > 1 && txMean !== null ? (a.txSquares - a.txN * txMean * txMean) / (a.txN - 1) : 0;
|
const txVar = a.txN > 1 && txMean !== null ? (a.txSquares - a.txN * txMean * txMean) / (a.txN - 1) : 0;
|
||||||
return {
|
out.push({
|
||||||
month: m + 1,
|
doy,
|
||||||
tn: tnMean !== null ? +tnMean.toFixed(2) : null,
|
tn: tnMean !== null ? +tnMean.toFixed(2) : null,
|
||||||
tx: txMean !== null ? +txMean.toFixed(2) : null,
|
tx: txMean !== null ? +txMean.toFixed(2) : null,
|
||||||
tnStd: +Math.sqrt(Math.max(0, tnVar)).toFixed(2),
|
tnStd: +Math.sqrt(Math.max(0, tnVar)).toFixed(2),
|
||||||
txStd: +Math.sqrt(Math.max(0, txVar)).toFixed(2),
|
txStd: +Math.sqrt(Math.max(0, txVar)).toFixed(2),
|
||||||
n: Math.min(a.tnN, a.txN),
|
n: Math.min(a.tnN, a.txN),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lissage léger sur fenêtre 7 jours pour réduire le bruit jour-à-jour (la T° d'un 12 juin
|
||||||
|
// n'est pas significativement différente d'un 13 juin sur 30 ans). Mode wrap-around pour les bordures.
|
||||||
|
const smoothed = out.map((_, i) => {
|
||||||
|
let txSum = 0, txN = 0, tnSum = 0, tnN = 0;
|
||||||
|
let stdTxSum = 0, stdTnSum = 0, stdN = 0;
|
||||||
|
for (let k = -3; k <= 3; k++) {
|
||||||
|
const j = (i + k + 366) % 366; // wrap
|
||||||
|
const w = out[j];
|
||||||
|
if (w.tx !== null) { txSum += w.tx; txN++; stdTxSum += w.txStd; }
|
||||||
|
if (w.tn !== null) { tnSum += w.tn; tnN++; stdTnSum += w.tnStd; }
|
||||||
|
stdN++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
doy: out[i].doy,
|
||||||
|
tn: tnN > 0 ? +(tnSum / tnN).toFixed(2) : null,
|
||||||
|
tx: txN > 0 ? +(txSum / txN).toFixed(2) : null,
|
||||||
|
tnStd: stdN > 0 ? +(stdTnSum / stdN).toFixed(2) : 0,
|
||||||
|
txStd: stdN > 0 ? +(stdTxSum / stdN).toFixed(2) : 0,
|
||||||
|
n: out[i].n,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return smoothed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
@ -110,6 +149,10 @@ for (const dept of DEPTS) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2A et 2B partagent les données code "20"
|
||||||
|
if (all['2A']) all['2B'] = all['2A'];
|
||||||
|
else if (all['2B']) all['2A'] = all['2B'];
|
||||||
|
|
||||||
mkdirSync(dirname(OUT), { recursive: true });
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
writeFileSync(OUT, JSON.stringify(all));
|
writeFileSync(OUT, JSON.stringify(all));
|
||||||
const dur = ((Date.now() - start) / 1000 / 60).toFixed(1);
|
const dur = ((Date.now() - start) / 1000 / 60).toFixed(1);
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ for (const [code] of entries) {
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Carte des départements français colorée selon le niveau Vigilance"
|
aria-label="Carte des départements français colorée selon le niveau Vigilance"
|
||||||
class="h-auto w-full max-w-5xl mx-auto"
|
class="h-auto w-full"
|
||||||
id="france-map"
|
id="france-map"
|
||||||
>
|
>
|
||||||
<title>Carte Vigilance Météo France</title>
|
<title>Carte Vigilance Météo France</title>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,35 @@
|
||||||
---
|
---
|
||||||
import type { DayObservation } from '../lib/climato';
|
import type { DayObservation } from '../lib/climato';
|
||||||
import type { HourlyObservation, HourlySeries } from '../lib/observations';
|
import type { HourlyObservation, HourlySeries } from '../lib/observations';
|
||||||
import type { MonthNormale } from '../lib/normales';
|
|
||||||
|
|
||||||
|
interface NormalePoint { tx: number | null; tn: number | null }
|
||||||
interface Props {
|
interface Props {
|
||||||
hourly?: HourlySeries | null; // 24h
|
hourly?: HourlySeries | null; // 24h
|
||||||
days7: DayObservation[];
|
days7: DayObservation[];
|
||||||
days30: DayObservation[];
|
days30: DayObservation[];
|
||||||
normale: MonthNormale | null;
|
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
|
||||||
stationLabel?: string | null;
|
stationLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hourly, days7, days30, normale, stationLabel } = Astro.props;
|
const { hourly, days7, days30, normales7 = [], normales30 = [], normaleHourly = null, stationLabel } = Astro.props;
|
||||||
|
|
||||||
// Sérialiser les 3 séries pour le JS client (toggle + hover).
|
// 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) => ({ t: d.date, tx: d.tx, tn: d.tn })),
|
days7: days7.map((d, i) => ({
|
||||||
days30: days30.map((d) => ({ t: d.date, tx: d.tx, tn: d.tn })),
|
t: d.date, tx: d.tx, tn: d.tn,
|
||||||
normale: normale ? { tx: normale.tx, tn: normale.tn } : null,
|
normTx: normales7[i]?.tx ?? null, normTn: normales7[i]?.tn ?? null,
|
||||||
|
})),
|
||||||
|
days30: days30.map((d, i) => ({
|
||||||
|
t: d.date, tx: d.tx, tn: d.tn,
|
||||||
|
normTx: normales30[i]?.tx ?? null, normTn: normales30[i]?.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;
|
||||||
|
|
@ -76,12 +87,10 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-canicule-600"></span> T° max (jour) / T° (heure)</span>
|
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-canicule-600"></span> T° max (jour) / T° (heure)</span>
|
||||||
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-blue-500"></span> T° min (jour)</span>
|
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-blue-500"></span> T° min (jour)</span>
|
||||||
{normale && (
|
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-px w-4 border-t-2 border-dashed border-slate-400"></span>
|
<span class="inline-block h-px w-4 border-t-2 border-dashed border-slate-400"></span>
|
||||||
Normale mois (1991-2020) : TX <span class="font-mono">{normale.tx}°C</span>, TN <span class="font-mono">{normale.tn}°C</span>
|
Normales journalières (1991-2020, lissage 7 j)
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
|
@ -114,12 +123,18 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Y range : inclure normales si dispo
|
// Y range : inclure normales si dispo (séries ou point unique)
|
||||||
const txVals = series.map((p) => p.tx).filter((v) => v !== null);
|
const txVals = series.map((p) => p.tx).filter((v) => v !== null);
|
||||||
const tnVals = series.map((p) => p.tn).filter((v) => v !== null);
|
const tnVals = series.map((p) => p.tn).filter((v) => v !== null);
|
||||||
const allVals = [...txVals, ...tnVals];
|
const allVals = [...txVals, ...tnVals];
|
||||||
if (data.normale?.tx !== null && data.normale?.tx !== undefined) allVals.push(data.normale.tx);
|
for (const p of series) {
|
||||||
if (data.normale?.tn !== null && data.normale?.tn !== undefined) allVals.push(data.normale.tn);
|
if (p.normTx !== null && p.normTx !== undefined) allVals.push(p.normTx);
|
||||||
|
if (p.normTn !== null && p.normTn !== undefined) allVals.push(p.normTn);
|
||||||
|
}
|
||||||
|
if (period === '24h' && data.normaleHourly) {
|
||||||
|
if (typeof data.normaleHourly.tx === 'number') allVals.push(data.normaleHourly.tx);
|
||||||
|
if (typeof data.normaleHourly.tn === 'number') allVals.push(data.normaleHourly.tn);
|
||||||
|
}
|
||||||
if (allVals.length === 0) return;
|
if (allVals.length === 0) return;
|
||||||
const yMin = Math.floor(Math.min(...allVals) - 1);
|
const yMin = Math.floor(Math.min(...allVals) - 1);
|
||||||
const yMax = Math.ceil(Math.max(...allVals) + 1);
|
const yMax = Math.ceil(Math.max(...allVals) + 1);
|
||||||
|
|
@ -151,16 +166,26 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
html += `<line x1="${PAD_L}" x2="${PAD_L}" y1="${PAD_T}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
html += `<line x1="${PAD_L}" x2="${PAD_L}" y1="${PAD_T}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
||||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${H - PAD_B}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${H - PAD_B}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
||||||
|
|
||||||
// Normales horizontales
|
// Normales : ligne horizontale en 24h (la T° normale du jour est constante), courbe en 7j/30j
|
||||||
if (data.normale) {
|
if (period === '24h' && data.normaleHourly) {
|
||||||
if (typeof data.normale.tx === 'number') {
|
if (typeof data.normaleHourly.tx === 'number') {
|
||||||
const y = yAt(data.normale.tx);
|
const y = yAt(data.normaleHourly.tx);
|
||||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#ea580c" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#ea580c" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
||||||
}
|
}
|
||||||
if (typeof data.normale.tn === 'number') {
|
if (typeof data.normaleHourly.tn === 'number') {
|
||||||
const y = yAt(data.normale.tn);
|
const y = yAt(data.normaleHourly.tn);
|
||||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#3b82f6" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#3b82f6" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Courbe normTx
|
||||||
|
const normTxPath = series
|
||||||
|
.map((p, i) => p.normTx !== null && p.normTx !== undefined ? `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.normTx).toFixed(1)}` : null)
|
||||||
|
.filter(Boolean).join('');
|
||||||
|
if (normTxPath) html += `<path d="${normTxPath}" fill="none" stroke="#ea580c" stroke-width="1" stroke-dasharray="4,3" opacity="0.5"/>`;
|
||||||
|
const normTnPath = series
|
||||||
|
.map((p, i) => p.normTn !== null && p.normTn !== undefined ? `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.normTn).toFixed(1)}` : null)
|
||||||
|
.filter(Boolean).join('');
|
||||||
|
if (normTnPath) html += `<path d="${normTnPath}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="4,3" opacity="0.5"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TX line
|
// TX line
|
||||||
|
|
@ -216,6 +241,12 @@ const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||||
const lines = [fmtTime(p.t, period)];
|
const lines = [fmtTime(p.t, period)];
|
||||||
if (p.tx !== null) lines.push(`<span class="text-canicule-700">T${period === '24h' ? '°' : 'X'} ${p.tx}°C</span>`);
|
if (p.tx !== null) lines.push(`<span class="text-canicule-700">T${period === '24h' ? '°' : 'X'} ${p.tx}°C</span>`);
|
||||||
if (p.tn !== null) lines.push(`<span class="text-blue-700">TN ${p.tn}°C</span>`);
|
if (p.tn !== null) lines.push(`<span class="text-blue-700">TN ${p.tn}°C</span>`);
|
||||||
|
// Normale du jour (overlay) — affiche écart si dispo
|
||||||
|
if (period !== '24h' && (p.normTx !== null && p.normTx !== undefined) && p.tx !== null) {
|
||||||
|
const diff = (p.tx - p.normTx).toFixed(1);
|
||||||
|
const sign = p.tx >= p.normTx ? '+' : '';
|
||||||
|
lines.push(`<span class="text-slate-500">↳ normale TX ${p.normTx}°C (${sign}${diff})</span>`);
|
||||||
|
}
|
||||||
tip.innerHTML = lines.join('<br>');
|
tip.innerHTML = lines.join('<br>');
|
||||||
tip.classList.remove('hidden');
|
tip.classList.remove('hidden');
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,23 +1,24 @@
|
||||||
// Lookup des normales saisonnières TN/TX par dept × mois, calculées sur 1991-2020.
|
// Lookup des normales saisonnières TN/TX par dept × jour (day-of-year 1..366),
|
||||||
// Données générées par scripts/build-normales.mjs, committées en JSON statique.
|
// calculées sur 1991-2020 avec lissage 7 jours. Données générées par
|
||||||
|
// scripts/build-normales.mjs, committées en JSON statique.
|
||||||
|
|
||||||
import type { DayObservation } from './climato';
|
import type { DayObservation } from './climato';
|
||||||
|
|
||||||
let normalesData: Record<string, MonthNormale[] | null> | null = null;
|
let normalesData: Record<string, DailyNormale[] | null> | null = null;
|
||||||
|
|
||||||
async function loadNormales(): Promise<Record<string, MonthNormale[] | null>> {
|
async function loadNormales(): Promise<Record<string, DailyNormale[] | null>> {
|
||||||
if (normalesData) return normalesData;
|
if (normalesData) return normalesData;
|
||||||
try {
|
try {
|
||||||
const m = await import('../data/normales.json');
|
const m = await import('../data/normales.json');
|
||||||
normalesData = m.default as Record<string, MonthNormale[] | null>;
|
normalesData = m.default as Record<string, DailyNormale[] | null>;
|
||||||
} catch {
|
} catch {
|
||||||
normalesData = {};
|
normalesData = {};
|
||||||
}
|
}
|
||||||
return normalesData;
|
return normalesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthNormale {
|
export interface DailyNormale {
|
||||||
month: number; // 1..12
|
doy: number; // 1..366
|
||||||
tn: number | null;
|
tn: number | null;
|
||||||
tx: number | null;
|
tx: number | null;
|
||||||
tnStd: number;
|
tnStd: number;
|
||||||
|
|
@ -25,32 +26,58 @@ export interface MonthNormale {
|
||||||
n: number;
|
n: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function normaleForMonth(dept: string, month: number): Promise<MonthNormale | null> {
|
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
|
||||||
|
// Les années non-bissextiles : on bascule simplement le 1er mars au doy 60 si pas 29 fév.
|
||||||
|
// Mais pour matcher l'indexation du JSON (1..366), on utilise une convention stable.
|
||||||
|
export function dayOfYear(date: Date): number {
|
||||||
|
const y = date.getUTCFullYear();
|
||||||
|
const m = date.getUTCMonth() + 1;
|
||||||
|
const d = date.getUTCDate();
|
||||||
|
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||||
|
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||||
|
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||||
|
return (isLeap ? cumulLeap : cumulNonLeap)[m - 1] + d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normaleForDay(dept: string, doy: number): Promise<DailyNormale | null> {
|
||||||
const data = await loadNormales();
|
const data = await loadNormales();
|
||||||
const arr = data[dept];
|
const arr = data[dept];
|
||||||
if (!arr) return null;
|
if (!arr) return null;
|
||||||
return arr[month - 1] ?? null;
|
return arr[doy - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normaleForDate(dept: string, date: Date): Promise<DailyNormale | null> {
|
||||||
|
return normaleForDay(dept, dayOfYear(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Série continue de normales pour une plage de dates : [{date, doy, tn, tx, ...}]
|
||||||
|
export async function normalesForRange(dept: string, dates: string[]): Promise<Array<{ date: string; tn: number | null; tx: number | null; tnStd: number; txStd: number } | null>> {
|
||||||
|
const data = await loadNormales();
|
||||||
|
const arr = data[dept];
|
||||||
|
if (!arr) return dates.map(() => null);
|
||||||
|
return dates.map((iso) => {
|
||||||
|
const doy = dayOfYear(new Date(iso));
|
||||||
|
const n = arr[doy - 1];
|
||||||
|
if (!n) return null;
|
||||||
|
return { date: iso, tn: n.tn, tx: n.tx, tnStd: n.tnStd, txStd: n.txStd };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnomalyCategory =
|
export type AnomalyCategory =
|
||||||
| 'normal'
|
| 'normal' | 'warm' | 'cool'
|
||||||
| 'warm'
|
| 'anomaly_warm' | 'anomaly_cool'
|
||||||
| 'cool'
|
| 'extreme_warm' | 'extreme_cool'
|
||||||
| 'anomaly_warm'
|
|
||||||
| 'anomaly_cool'
|
|
||||||
| 'extreme_warm'
|
|
||||||
| 'extreme_cool'
|
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
|
|
||||||
export interface Anomaly {
|
export interface Anomaly {
|
||||||
windowDays: number; // fenêtre dominante retenue (3 ou 7)
|
windowDays: number;
|
||||||
meanTx: number | null;
|
meanTx: number | null;
|
||||||
meanTn: number | null;
|
meanTn: number | null;
|
||||||
normaleTx: number | null;
|
normaleTx: number | null;
|
||||||
normaleTn: number | null;
|
normaleTn: number | null;
|
||||||
diffTx: number | null;
|
diffTx: number | null;
|
||||||
diffTn: number | null;
|
diffTn: number | null;
|
||||||
sigmaTx: number | null; // diffTx / txStd
|
sigmaTx: number | null;
|
||||||
sigmaTn: number | null;
|
sigmaTn: number | null;
|
||||||
txCategory: AnomalyCategory;
|
txCategory: AnomalyCategory;
|
||||||
}
|
}
|
||||||
|
|
@ -64,42 +91,84 @@ function categorize(sigma: number | null): AnomalyCategory {
|
||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sévérité ordonnée pour comparer 2 fenêtres et retenir la pire.
|
|
||||||
const SEVERITY: Record<AnomalyCategory, number> = {
|
const SEVERITY: Record<AnomalyCategory, number> = {
|
||||||
unknown: -1, normal: 0, cool: 1, warm: 1,
|
unknown: -1, normal: 0, cool: 1, warm: 1,
|
||||||
anomaly_cool: 2, anomaly_warm: 2,
|
anomaly_cool: 2, anomaly_warm: 2,
|
||||||
extreme_cool: 3, extreme_warm: 3,
|
extreme_cool: 3, extreme_warm: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildWindow(days: DayObservation[], windowSize: number, nrm: MonthNormale): Omit<Anomaly, 'txCategory'> & { txCategory: AnomalyCategory } {
|
async function buildWindow(
|
||||||
|
dept: string,
|
||||||
|
days: DayObservation[],
|
||||||
|
windowSize: number,
|
||||||
|
): Promise<Omit<Anomaly, 'txCategory'> & { txCategory: AnomalyCategory }> {
|
||||||
const recent = days.slice(-windowSize);
|
const recent = days.slice(-windowSize);
|
||||||
const txs = recent.map((d) => d.tx).filter((v): v is number => v !== null);
|
// Comparer chaque jour observé à SA normale du jour, puis moyenner les écarts.
|
||||||
const tns = recent.map((d) => d.tn).filter((v): v is number => v !== null);
|
// Plus juste que de moyenner les T° et comparer à une normale moyennée.
|
||||||
const meanTx = txs.length ? +(txs.reduce((s, v) => s + v, 0) / txs.length).toFixed(1) : null;
|
const data = await loadNormales();
|
||||||
const meanTn = tns.length ? +(tns.reduce((s, v) => s + v, 0) / tns.length).toFixed(1) : null;
|
const arr = data[dept];
|
||||||
const diffTx = meanTx !== null && nrm.tx !== null ? +(meanTx - nrm.tx).toFixed(1) : null;
|
|
||||||
const diffTn = meanTn !== null && nrm.tn !== null ? +(meanTn - nrm.tn).toFixed(1) : null;
|
let txN = 0, txSumDiff = 0, txSumStd2 = 0, txSumTx = 0;
|
||||||
const sigmaTx = diffTx !== null && nrm.txStd > 0 ? +(diffTx / nrm.txStd).toFixed(2) : null;
|
let tnN = 0, tnSumDiff = 0, tnSumStd2 = 0, tnSumTn = 0;
|
||||||
const sigmaTn = diffTn !== null && nrm.tnStd > 0 ? +(diffTn / nrm.tnStd).toFixed(2) : null;
|
let normaleTxSum = 0, normaleTxN = 0, normaleTnSum = 0, normaleTnN = 0;
|
||||||
|
|
||||||
|
for (const d of recent) {
|
||||||
|
if (!arr) break;
|
||||||
|
const doy = dayOfYear(new Date(d.date));
|
||||||
|
const n = arr[doy - 1];
|
||||||
|
if (!n) continue;
|
||||||
|
if (d.tx !== null && n.tx !== null) {
|
||||||
|
txN++;
|
||||||
|
txSumDiff += d.tx - n.tx;
|
||||||
|
txSumStd2 += n.txStd * n.txStd;
|
||||||
|
txSumTx += d.tx;
|
||||||
|
normaleTxSum += n.tx; normaleTxN++;
|
||||||
|
}
|
||||||
|
if (d.tn !== null && n.tn !== null) {
|
||||||
|
tnN++;
|
||||||
|
tnSumDiff += d.tn - n.tn;
|
||||||
|
tnSumStd2 += n.tnStd * n.tnStd;
|
||||||
|
tnSumTn += d.tn;
|
||||||
|
normaleTnSum += n.tn; normaleTnN++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meanTx = txN > 0 ? +(txSumTx / txN).toFixed(1) : null;
|
||||||
|
const meanTn = tnN > 0 ? +(tnSumTn / tnN).toFixed(1) : null;
|
||||||
|
const normaleTx = normaleTxN > 0 ? +(normaleTxSum / normaleTxN).toFixed(1) : null;
|
||||||
|
const normaleTn = normaleTnN > 0 ? +(normaleTnSum / normaleTnN).toFixed(1) : null;
|
||||||
|
const diffTx = txN > 0 ? +(txSumDiff / txN).toFixed(1) : null;
|
||||||
|
const diffTn = tnN > 0 ? +(tnSumDiff / tnN).toFixed(1) : null;
|
||||||
|
// σ effectif = racine de la moyenne des variances (combinaison de jours différents)
|
||||||
|
const stdTx = txN > 0 ? Math.sqrt(txSumStd2 / txN) : 0;
|
||||||
|
const stdTn = tnN > 0 ? Math.sqrt(tnSumStd2 / tnN) : 0;
|
||||||
|
const sigmaTx = diffTx !== null && stdTx > 0 ? +(diffTx / stdTx).toFixed(2) : null;
|
||||||
|
const sigmaTn = diffTn !== null && stdTn > 0 ? +(diffTn / stdTn).toFixed(2) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
windowDays: recent.length, meanTx, meanTn,
|
windowDays: recent.length, meanTx, meanTn,
|
||||||
normaleTx: nrm.tx, normaleTn: nrm.tn,
|
normaleTx, normaleTn, diffTx, diffTn, sigmaTx, sigmaTn,
|
||||||
diffTx, diffTn, sigmaTx, sigmaTn,
|
|
||||||
txCategory: categorize(sigmaTx),
|
txCategory: categorize(sigmaTx),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function computeAnomaly(dept: string, days: DayObservation[]): Promise<Anomaly | null> {
|
export async function computeAnomaly(dept: string, days: DayObservation[]): Promise<Anomaly | null> {
|
||||||
if (days.length === 0) return null;
|
if (days.length === 0) return null;
|
||||||
const middleDate = days.slice(-7)[Math.floor(Math.min(7, days.length) / 2)].date;
|
const w3 = await buildWindow(dept, days, 3);
|
||||||
const month = parseInt(middleDate.slice(5, 7), 10);
|
const w7 = await buildWindow(dept, days, 7);
|
||||||
const nrm = await normaleForMonth(dept, month);
|
|
||||||
if (!nrm) return null;
|
|
||||||
|
|
||||||
// Évalue 2 fenêtres en parallèle : 3 jours (détecte les pics récents) et 7 jours (tendance lissée).
|
|
||||||
// Retient la pire catégorie pour éviter de noyer un événement aigu dans un lissage trop long.
|
|
||||||
const w3 = buildWindow(days, 3, nrm);
|
|
||||||
const w7 = buildWindow(days, 7, nrm);
|
|
||||||
const worst = (SEVERITY[w3.txCategory] ?? -1) >= (SEVERITY[w7.txCategory] ?? -1) ? w3 : w7;
|
const worst = (SEVERITY[w3.txCategory] ?? -1) >= (SEVERITY[w7.txCategory] ?? -1) ? w3 : w7;
|
||||||
return worst;
|
return worst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compat : ancien helper utilisé ailleurs si présent
|
||||||
|
export async function normaleForMonth(dept: string, month: number): Promise<{ tx: number | null; tn: number | null; txStd: number; tnStd: number } | null> {
|
||||||
|
const data = await loadNormales();
|
||||||
|
const arr = data[dept];
|
||||||
|
if (!arr) return null;
|
||||||
|
// Moyenne les normales du mois pour rétro-compat
|
||||||
|
const mid = new Date(Date.UTC(2024, month - 1, 15));
|
||||||
|
const doy = dayOfYear(mid);
|
||||||
|
const n = arr[doy - 1];
|
||||||
|
if (!n) return null;
|
||||||
|
return { tx: n.tx, tn: n.tn, txStd: n.txStd, tnStd: n.tnStd };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,15 @@ export async function getHourlyForDepartement(dept: string, hours = 24): Promise
|
||||||
throw new Error('MF synop response not an array');
|
throw new Error('MF synop response not an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'API SYNOP de Météo France retourne chaque obs en doublon exact sur les ranges multi-step
|
||||||
|
// (bug du gateway, vérifié 2026-05-26). Dedupe par validity_time avant de mapper.
|
||||||
|
const seen = new Set<string>();
|
||||||
const observations: HourlyObservation[] = rows
|
const observations: HourlyObservation[] = rows
|
||||||
.filter((r) => r.validity_time)
|
.filter((r) => {
|
||||||
|
if (!r.validity_time || seen.has(r.validity_time)) return false;
|
||||||
|
seen.add(r.validity_time);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
time: r.validity_time!,
|
time: r.validity_time!,
|
||||||
t: K2C(num(r.t)),
|
t: K2C(num(r.t)),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { getDepartement, isDrom } from '../../lib/departements';
|
||||||
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
|
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
|
||||||
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
|
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
|
||||||
import { getClimatoForDepartement } from '../../lib/climato';
|
import { getClimatoForDepartement } from '../../lib/climato';
|
||||||
import { computeAnomaly, normaleForMonth } from '../../lib/normales';
|
import { computeAnomaly, normaleForDate, normalesForRange } from '../../lib/normales';
|
||||||
import { getHourlyForDepartement } from '../../lib/observations';
|
import { getHourlyForDepartement } from '../../lib/observations';
|
||||||
import TemperatureChartInteractive from '../../components/TemperatureChartInteractive.astro';
|
import TemperatureChartInteractive from '../../components/TemperatureChartInteractive.astro';
|
||||||
import AnomalyBadge from '../../components/AnomalyBadge.astro';
|
import AnomalyBadge from '../../components/AnomalyBadge.astro';
|
||||||
|
|
@ -36,20 +36,18 @@ if (!drom) {
|
||||||
let climato = null;
|
let climato = null;
|
||||||
let anomaly = null;
|
let anomaly = null;
|
||||||
let hourly = null;
|
let hourly = null;
|
||||||
let normale = null;
|
let normales7: Array<{ tx: number | null; tn: number | null }> = [];
|
||||||
|
let normales30: Array<{ tx: number | null; tn: number | null }> = [];
|
||||||
|
let normaleHourly: { tx: number | null; tn: number | null } | null = null;
|
||||||
if (!drom) {
|
if (!drom) {
|
||||||
try {
|
try {
|
||||||
climato = await getClimatoForDepartement(dept.code);
|
climato = await getClimatoForDepartement(dept.code);
|
||||||
if (climato?.days?.length) {
|
if (climato?.days?.length) {
|
||||||
anomaly = await computeAnomaly(dept.code, climato.days);
|
anomaly = await computeAnomaly(dept.code, climato.days);
|
||||||
// Mois représentatif = mois du dernier jour climato dispo
|
|
||||||
const lastDate = climato.days[climato.days.length - 1].date;
|
|
||||||
normale = await normaleForMonth(dept.code, parseInt(lastDate.slice(5, 7), 10));
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('climato fetch failed for', dept.code, (e as Error).message);
|
console.warn('climato fetch failed for', dept.code, (e as Error).message);
|
||||||
}
|
}
|
||||||
// Hourly est best-effort : si MF API down ou pas de creds, on n'affiche juste pas l'onglet 24h
|
|
||||||
try {
|
try {
|
||||||
hourly = await getHourlyForDepartement(dept.code, 24);
|
hourly = await getHourlyForDepartement(dept.code, 24);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -59,6 +57,17 @@ 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) ?? [];
|
||||||
|
|
||||||
|
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)
|
||||||
|
normaleHourly = await normaleForDate(dept.code, new Date());
|
||||||
|
if (normaleHourly) normaleHourly = { tx: normaleHourly.tx, tn: normaleHourly.tn };
|
||||||
|
}
|
||||||
|
|
||||||
const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
|
const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
|
||||||
|
|
||||||
const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
|
const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
|
||||||
|
|
@ -178,7 +187,9 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
|
||||||
hourly={hourly}
|
hourly={hourly}
|
||||||
days7={last7}
|
days7={last7}
|
||||||
days30={last30}
|
days30={last30}
|
||||||
normale={normale}
|
normales7={normales7}
|
||||||
|
normales30={normales30}
|
||||||
|
normaleHourly={normaleHourly}
|
||||||
stationLabel={stationLabel}
|
stationLabel={stationLabel}
|
||||||
/>
|
/>
|
||||||
<p class="mt-2 text-xs text-slate-500">
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
|
|
||||||
|
|
@ -100,18 +100,21 @@ const productDate = snapshot?.productDatetime
|
||||||
|
|
||||||
{
|
{
|
||||||
!error && (
|
!error && (
|
||||||
<section class="container-tight pb-8">
|
<>
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<section class="container-tight pb-2">
|
||||||
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 class="text-xl font-semibold text-slate-900">Niveau par département (aujourd'hui)</h2>
|
<h2 class="text-xl font-semibold text-slate-900">Niveau par département (aujourd'hui)</h2>
|
||||||
<VigilanceLegend />
|
<VigilanceLegend />
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-3 text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
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>
|
||||||
<div class="flex justify-center">
|
</section>
|
||||||
|
<div class="mx-auto w-full max-w-[1400px] px-4 pb-4 sm:px-6">
|
||||||
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
|
<FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
|
||||||
</div>
|
</div>
|
||||||
<details class="mt-6 rounded border border-slate-200 bg-white p-4">
|
<section class="container-tight pb-8">
|
||||||
|
<details class="rounded border border-slate-200 bg-white p-4">
|
||||||
<summary class="cursor-pointer font-medium text-slate-700">
|
<summary class="cursor-pointer font-medium text-slate-700">
|
||||||
Vue par région (liste)
|
Vue par région (liste)
|
||||||
</summary>
|
</summary>
|
||||||
|
|
@ -136,6 +139,7 @@ const productDate = snapshot?.productDatetime
|
||||||
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700">vigilance.meteofrance.fr</a>
|
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700">vigilance.meteofrance.fr</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue