info-canicule/src/pages/departement/[code].astro
Florian 9db5d4c204
Some checks failed
Deploy info-canicule / deploy (push) Failing after 4s
fix(climato): fiabiliser les jours récents à couverture station partielle
Les 1-2 derniers jours du flux climato `latest` ont souvent 0-1 station
publiée (parfois TN sans TX), produisant des agrégats département nuls ou
non représentatifs. Symptômes côté page dept : TX/TN vides ou tx==tn à
J-1/J-2 sur le graphe, et moyenne 3j/7j figée (computeAnomaly lisait
climato.days brut et retombait sur les mêmes vieux jours complets).

- climato.ts : exposer la couverture réelle par champ (txN/tnN, optionnels,
  rétro-compatibles avec le cache 24h).
- [code].astro : `_mergedDay` choisit par champ la source fiable —
  climato si couverture >= 50% de la médiane station du dept, sinon SYNOP
  (garde >= 3 obs/jour pour éviter tx==tn), sinon valeur fine, sinon null.
  Le badge anomalie utilise désormais les 7 derniers jours complets
  (finissant J-1) au lieu de climato.days brut, recollant graphe et badge.

Vérifié par simulation sur données réelles (depts 31/75/13) : jours pleins
inchangés (zéro régression), jours récents partiels comblés proprement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:04:34 +02:00

483 lines
23 KiB
Text

---
import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro';
import { getVigilanceSnapshot, alertsForDepartement, currentEcheance, hasJ1Period } from '../../lib/vigilance';
import { getDepartement, isDrom } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL, COLORS } from '../../lib/phenomena';
import type { PhenomenonId, ColorId } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
import { getClimatoForDepartement } from '../../lib/climato';
import { computeAnomaly, normaleForDate, normalesForRange } from '../../lib/normales';
import { getHourlyForDepartement } from '../../lib/observations';
import TemperatureChartInteractive from '../../components/TemperatureChartInteractive.astro';
import AnomalyBadge from '../../components/AnomalyBadge.astro';
export const prerender = false;
const { code } = Astro.params;
const dept = code ? getDepartement(code.toUpperCase()) : undefined;
if (!dept) {
return new Response('Département introuvable', { status: 404 });
}
Astro.response.headers.set('Cache-Control', 'public, max-age=60, must-revalidate');
const drom = isDrom(dept.code);
const [snapshotR, climatoR, hourlyR] = drom
? [Promise.resolve(null), Promise.resolve(null), Promise.resolve(null)] as const
: ([
getVigilanceSnapshot(),
getClimatoForDepartement(dept.code),
getHourlyForDepartement(dept.code, 48),
] as const);
const settled = await Promise.allSettled([snapshotR, climatoR, hourlyR]);
let snapshot;
let error: string | null = null;
let climato = null;
let hourly = null;
if (settled[0].status === 'fulfilled') snapshot = settled[0].value as Awaited<ReturnType<typeof getVigilanceSnapshot>> | null;
else error = (settled[0].reason as Error).message;
if (settled[1].status === 'fulfilled') climato = settled[1].value as Awaited<ReturnType<typeof getClimatoForDepartement>> | null;
else console.warn('climato fetch failed for', dept.code, (settled[1].reason as Error).message);
if (settled[2].status === 'fulfilled') hourly = settled[2].value as Awaited<ReturnType<typeof getHourlyForDepartement>> | null;
else console.warn('hourly fetch failed for', dept.code, (settled[2].reason as Error).message);
let anomaly = null;
let normales7: Array<{ tx: number | null; tn: number | null }> = [];
let normales30: Array<{ tx: number | null; tn: number | null }> = [];
let normales365: Array<{ tx: number | null; tn: number | null }> = [];
let normaleHourly: { tx: number | null; tn: number | null } | null = null;
// Build calendar-based windows ending at today (Paris time).
// Source data has a ~1-4 day publication lag AND the most recent published day(s) often have
// drastically thin station coverage (0-1 station, sometimes TX missing) — so a per-day dept
// aggregate is only trustworthy when enough stations reported. Below that threshold we prefer
// the SYNOP nearest-station window, which is consistent day-to-day. See plan/commit message.
const _todayParis = new Intl.DateTimeFormat('sv', { timeZone: 'Europe/Paris' }).format(new Date());
const _climaByDate = new Map((climato?.days ?? []).map((d) => [d.date, d]));
// Minimum coverage for a climato day to be trusted: half the dept's typical station count
// (median over days that reported anything), floored at 2.
const _statCounts = (climato?.days ?? []).map((d) => d.stations).filter((n) => n > 0).sort((a, b) => a - b);
const _typicalStations = _statCounts.length ? _statCounts[Math.floor(_statCounts.length / 2)] : 0;
const _minCoverage = Math.max(2, Math.ceil(_typicalStations * 0.5));
// Derive daily TX/TN from the 48h SYNOP window to fill the climato gap (typically 1-2 days).
// TX = max hourly t, TN = min hourly t. Require >= MIN_SYNOP_OBS observations: a day with a single
// observation would yield tx == tn (a degenerate, misleading point).
const MIN_SYNOP_OBS = 3;
const _synopDaily = new Map<string, { tx: number; tn: number }>();
if (hourly) {
const _byDate = new Map<string, number[]>();
for (const obs of hourly.observations) {
if (obs.t === null) continue;
const d = new Intl.DateTimeFormat('sv', { timeZone: 'Europe/Paris' }).format(new Date(obs.time));
if (!_byDate.has(d)) _byDate.set(d, []);
_byDate.get(d)!.push(obs.t);
}
for (const [date, temps] of _byDate) {
if (temps.length < MIN_SYNOP_OBS) continue;
_synopDaily.set(date, { tx: +Math.max(...temps).toFixed(1), tn: +Math.min(...temps).toFixed(1) });
}
}
// Per-field preference for a given calendar date:
// 1. reliable climato (enough stations + non-null) → dept aggregate (best);
// 2. else SYNOP-derived value (consistent nearby station);
// 3. else thin climato value (better than nothing when no SYNOP);
// 4. else null (honest gap).
function _mergedDay(dateStr: string) {
const c = _climaByDate.get(dateStr);
const s = _synopDaily.get(dateStr);
const txReliable = !!c && c.tx !== null && (c.txN ?? c.stations ?? 0) >= _minCoverage;
const tnReliable = !!c && c.tn !== null && (c.tnN ?? c.stations ?? 0) >= _minCoverage;
const tx = txReliable ? c!.tx : (s?.tx ?? c?.tx ?? null);
const tn = tnReliable ? c!.tn : (s?.tn ?? c?.tn ?? null);
return { date: dateStr, tx, tn, tm: c?.tm ?? null, rr: c?.rr ?? null, stations: c?.stations ?? 0 };
}
// n days ending today (endOffset 0) or ending yesterday (endOffset 1, for complete-day windows).
function _buildRange(n: number, endOffset = 0) {
const result = [];
for (let i = n - 1 + endOffset; i >= endOffset; i--) {
const d = new Date(_todayParis + 'T12:00:00Z'); // noon UTC to avoid DST drift
d.setUTCDate(d.getUTCDate() - i);
const dateStr = new Intl.DateTimeFormat('sv', { timeZone: 'Europe/Paris' }).format(d);
result.push(_mergedDay(dateStr));
}
return result;
}
const last7 = climato ? _buildRange(7) : [];
const last30 = climato ? _buildRange(30) : [];
const last365 = climato ? _buildRange(365) : [];
if (!drom) {
if (climato?.days?.length) {
// Anomaly over the same calendar windows as the chart, ending YESTERDAY (today is incomplete).
// computeAnomaly re-slices internally for the 3-day / 7-day sub-windows.
anomaly = await computeAnomaly(dept.code, _buildRange(7, 1));
}
const [s7, s30, s365] = await Promise.all([
normalesForRange(dept.code, last7.map((d) => d.date)),
normalesForRange(dept.code, last30.map((d) => d.date)),
normalesForRange(dept.code, last365.map((d) => d.date)),
]);
normales7 = s7.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
normales30 = s30.map((n) => ({ tx: n?.tx ?? null, tn: n?.tn ?? null }));
normales365 = s365.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 };
}
// Pass only the last 24h slice to the chart (we fetched 48h for synop gap-filling above).
const hourlyFor24h = hourly
? { ...hourly, observations: hourly.observations.filter((o) => new Date(o.time).getTime() >= Date.now() - 24 * 3600 * 1000) }
: null;
const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
const ech = snapshot ? currentEcheance(snapshot) : 'J';
const j1Available = snapshot ? (ech === 'J' && hasJ1Period(snapshot)) : false;
const today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : [];
const tomorrow = j1Available ? alertsForDepartement(snapshot!, dept.code, 'J1') : [];
// Sort by severity desc, pick top alert for the hero block
const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId);
const topAlert = sortedToday[0] ?? null;
const otherAlerts = sortedToday.slice(1);
const adviceFor = topAlert ? ADVICE[topAlert.phenomenonId] : null;
// First 3 action items as quick bullets inside the hero block
const quickActions = adviceFor
? adviceFor.blocks.flatMap((b) => b.items).slice(0, 3)
: [];
// Per-phenomenon color maps for the comparison table
const PHENOM_IDS: PhenomenonId[] = [1, 2, 3, 5, 6, 8, 9];
const todayByPhenom = new Map<PhenomenonId, ColorId>();
for (const a of today) {
const prev = todayByPhenom.get(a.phenomenonId) ?? 0;
if (a.colorId > prev) todayByPhenom.set(a.phenomenonId, a.colorId);
}
const tomorrowByPhenom = new Map<PhenomenonId, ColorId>();
for (const a of tomorrow) {
const prev = tomorrowByPhenom.get(a.phenomenonId) ?? 0;
if (a.colorId > prev) tomorrowByPhenom.set(a.phenomenonId, a.colorId);
}
const comparisonRows = PHENOM_IDS.map((id) => ({
id,
phen: PHENOMENA[id],
todayColor: (todayByPhenom.get(id) ?? 1) as ColorId,
tomorrowColor: j1Available ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null,
changed: j1Available && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1),
}));
// Phenomena with no active alert today (green)
const activePhenomIds = new Set(today.map((a) => a.phenomenonId));
const inactivePhenomIds = PHENOM_IDS.filter((id) => !activePhenomIds.has(id));
const productDate = snapshot?.productDatetime
? new Date(snapshot.productDatetime).toLocaleString('fr-FR', {
dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris',
})
: null;
const fmtTime = (iso: string) =>
new Date(iso).toLocaleString('fr-FR', {
dateStyle: 'short', timeStyle: 'short', timeZone: 'Europe/Paris',
});
const labelDate = (iso: string) =>
new Date(iso).toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris',
});
const todayLabel = today[0] ? labelDate(today[0].beginTime) : '';
const tomorrowLabel = tomorrow[0] ? labelDate(tomorrow[0].beginTime) : '';
// Glyphs matching the pill levels
const GLYPHS: Record<ColorId, string> = { 1: '●', 2: '▲', 3: '◆', 4: '■' };
// Pre-computed display values for the hero alert block
const topColorName = topAlert ? COLORS[topAlert.colorId].name : null;
const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null;
---
<Base
title={`${dept.name} (${dept.code}) — Vigilance météo`}
description={`Niveau de Vigilance Météo France pour ${dept.name} (${dept.region}) et conseils officiels.`}
>
{/* ── Hero header ── */}
<section class="container-tight" style="padding-block: clamp(24px, 4vw, 48px) 20px;">
<a href="/" class="ic-btn ic-btn-ghost ic-btn-sm" style="text-decoration: none; margin-bottom: 20px; display: inline-flex;">
← Retour à la carte
</a>
<div style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 28px;">
<div class="kicker">{dept.region} · département {dept.code}</div>
<h1>{dept.name}</h1>
{productDate && (
<p class="kicker" style="text-transform: none; letter-spacing: 0; font-size: 0.82rem;">
Bulletin Météo France du {productDate}
</p>
)}
</div>
</section>
{/* ── Vigilance status ── */}
<section class="container-tight" style="padding-block: 0 32px;">
{/* DROM notice */}
{drom && (
<div class="v-block v-jaune" style="margin-bottom: 24px;">
<strong style="color: var(--v-jaune-ink);">▲ Vigilance Outre-mer non couverte par cette source</strong>
<p style="color: var(--v-jaune-ink); margin-top: 8px; font-size: 0.95rem;">
Les départements et régions d'Outre-mer disposent de leur propre dispositif Vigilance, géré
par les centres météorologiques locaux de Météo France. Ces données ne sont pas (encore)
rediffusées en open data au même format que la métropole.
</p>
<a
href="https://vigilance.meteofrance.fr/"
rel="noopener"
class="ic-btn ic-btn-sm"
style="text-decoration: none; margin-top: 14px; background: var(--v-jaune-ink); color: var(--v-jaune-bg); border-color: var(--v-jaune-ink);"
>
Voir la Vigilance officielle Outre-mer →
</a>
</div>
)}
{/* Error */}
{!drom && error && (
<div class="v-block v-rouge" style="margin-bottom: 24px;">
<strong style="color: var(--v-rouge-ink);">■ Données momentanément indisponibles</strong>
<p style="color: var(--v-rouge-ink); margin-top: 8px; font-size: 0.92rem;">
Réessayez dans quelques minutes. En urgence, consultez
<a href="https://vigilance.meteofrance.fr/" rel="noopener" style="color: inherit; text-decoration: underline;">vigilance.meteofrance.fr</a>.
</p>
</div>
)}
{!drom && !error && (
<>
{/* All-green state */}
{sortedToday.length === 0 && (
<div class="v-block v-vert" style="margin-bottom: 24px;">
<h2 style="color: var(--v-vert-ink); font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem);">
● Aucune vigilance active {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"}
</h2>
<p style="color: var(--v-vert-ink); margin-top: 10px; line-height: 1.5;">
Tous les phénomènes sont au vert pour {dept.name}. Restez attentif aux mises à jour Météo France.
</p>
</div>
)}
{/* Hero — top alert */}
{topAlert && topColorName && topPhen && (
<div class={`v-block v-${topColorName}`} style="margin-bottom: 24px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap;">
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">{topPhen.emoji}</span>
<h2 style={`color: var(--v-${topColorName}-ink); font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem);`}>
{GLYPHS[topAlert.colorId]} {topPhen.label} — {COLOR_LABEL[topAlert.colorId]}
</h2>
</div>
{adviceFor && (
<div style="margin-top: 4px; padding: 16px 20px; background: var(--paper-2); border-radius: var(--r-md); color: var(--ink);">
<strong style="font-size: 0.95rem;">Que faire maintenant ?</strong>
<ul style="margin-top: 10px; padding-left: 18px; color: var(--ink-2); line-height: 1.6;">
{quickActions.map((a) => <li>{a}</li>)}
</ul>
<a
href={`/conseils/${topPhen.slug}`}
style="display: inline-flex; align-items: center; gap: 6px; margin-top: 12px; font-size: 0.88rem; font-weight: 600; color: var(--brand-deep); text-decoration: none;"
>
Voir le kit complet {topPhen.label} →
</a>
</div>
)}
<p style={`color: var(--v-${topColorName}-ink); margin-top: 14px; font-size: 0.85rem; opacity: 0.85;`}>
Valide du {fmtTime(topAlert.beginTime)} au {fmtTime(topAlert.endTime)} — heure de Paris.
</p>
</div>
)}
{/* Other active alerts */}
{otherAlerts.map((a) => {
const colorName = COLORS[a.colorId].name;
const phen = PHENOMENA[a.phenomenonId];
return (
<div class={`v-block v-${colorName}`} style="margin-bottom: 12px; padding: 16px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.3rem;" aria-hidden="true">{phen.emoji}</span>
<strong style={`color: var(--v-${colorName}-ink);`}>{phen.label}</strong>
</div>
<VigilanceChip colorId={a.colorId} showLevel />
</div>
<p style={`color: var(--v-${colorName}-ink); margin-top: 8px; font-size: 0.82rem; opacity: 0.8;`}>
Du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)}
</p>
</div>
);
})}
{/* Collapsible: inactive phenomena (all green) */}
{inactivePhenomIds.length > 0 && (
<details class="ic-card" style="margin-bottom: 28px; padding: 16px;">
<summary style="cursor: pointer; font-weight: 600; color: var(--ink-2); display: flex; align-items: center; justify-content: space-between; list-style: none;">
<span>
{inactivePhenomIds.length} phénomène{inactivePhenomIds.length > 1 ? 's' : ''} — tous au vert
</span>
<span aria-hidden="true" style="color: var(--ink-mute); font-size: 0.8rem;">▼</span>
</summary>
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 14px;">
{inactivePhenomIds.map((id) => (
<div class="v-block v-vert" style="padding: 10px 14px; font-size: 0.9rem; display: flex; align-items: center; gap: 8px;">
<span aria-hidden="true">{PHENOMENA[id].emoji}</span>
<span style="color: var(--v-vert-ink);">{PHENOMENA[id].label}</span>
</div>
))}
</div>
</details>
)}
{/* Today vs Tomorrow comparison */}
{(today.length > 0 || tomorrow.length > 0 || j1Available) && (
<div class="ic-card" style="margin-bottom: 28px; overflow-x: auto;">
<h3 style="margin-bottom: 16px;">
Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''}
</h3>
<div class="comparison-grid" style="display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 6px 12px; align-items: center; min-width: 360px;">
<span class="kicker">Phénomène</span>
<span class="kicker" style="text-align: center;">Aujourd'hui</span>
{j1Available
? <span class="kicker" style="text-align: center;">Demain</span>
: <span class="kicker" style="text-align: center; opacity: 0.4;">Demain</span>
}
{comparisonRows.map(({ id, phen, todayColor, tomorrowColor, changed }) => (
<>
<span style="font-size: 0.9rem; font-weight: 500; display: flex; align-items: center; gap: 7px;">
<span aria-hidden="true">{phen.emoji}</span>{phen.label}
</span>
<span style="text-align: center;">
<VigilanceChip colorId={todayColor} />
</span>
<span style="text-align: center;">
{tomorrowColor !== null
? (
<span style="display: inline-flex; flex-direction: column; align-items: center; gap: 2px;">
<VigilanceChip colorId={tomorrowColor} />
{changed && <span style="font-size: 0.72rem; color: var(--brand-deep); font-weight: 600;">change</span>}
</span>
)
: <span style="color: var(--ink-mute); font-size: 0.85rem;">—</span>
}
</span>
</>
))}
</div>
</div>
)}
{/* Emergency numbers if there's an active alert */}
{topAlert && EMERGENCY_NUMBERS.length > 0 && (
<div class="ic-card-soft" style="margin-bottom: 28px; padding: 18px;">
<div class="kicker" style="margin-bottom: 12px;">Numéros d'urgence</div>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3">
{EMERGENCY_NUMBERS.map((n) => (
<div style="display: flex; align-items: baseline; gap: 8px;">
<a href={`tel:${n.num}`} style="font-family: var(--font-mono); font-weight: 700; font-size: 1.15rem; color: var(--v-rouge); text-decoration: none; border-bottom: none;">
{n.num}
</a>
<span style="color: var(--ink-soft); font-size: 0.88rem;">{n.label}</span>
</div>
))}
</div>
</div>
)}
</>
)}
</section>
{/* ── Température & climato ── */}
{climato && climato.days.length > 0 && (
<section class="container-tight" style="padding-block: 0 32px;">
<div class="ic-card" style="padding: clamp(16px, 3vw, 28px);">
<h2 style="font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem); margin-bottom: 20px;">Températures récentes</h2>
<TemperatureChartInteractive
hourly={hourlyFor24h}
days7={last7}
days30={last30}
days365={last365}
normales7={normales7}
normales30={normales30}
normales365={normales365}
normaleHourly={normaleHourly}
stationLabel={stationLabel}
/>
{anomaly && (
<div style="margin-top: 20px;">
<AnomalyBadge anomaly={anomaly} />
</div>
)}
<p style="margin-top: 12px; font-size: 0.82rem; color: var(--ink-soft);">
Sources : Météo France — observations SYNOP horaires (onglet 24 h) et données
climatologiques de base quotidiennes (7 j / 30 j / 365 j), agrégées par station la plus
proche. Normales 1991-2020 (référence WMO).
</p>
</div>
</section>
)}
{/* ── Actions rapides ── */}
{!drom && !error && (
<section class="container-tight" style="padding-block: 0 32px;">
<div class="grid gap-3 actions-grid">
{topAlert && (
<a
href={`/conseils/${PHENOMENA[topAlert.phenomenonId].slug}`}
class="ic-card ic-card-interactive"
style="text-decoration: none; color: inherit; padding: 20px; display: flex; flex-direction: column; gap: 8px;"
>
<span style="font-size: 1.6rem;" aria-hidden="true">{PHENOMENA[topAlert.phenomenonId].emoji}</span>
<strong style="font-size: 1rem;">Kit {PHENOMENA[topAlert.phenomenonId].label}</strong>
<span style="color: var(--ink-soft); font-size: 0.88rem; line-height: 1.4;">Conseils officiels, imprimables et partageables</span>
</a>
)}
<a
href="/conseils"
class="ic-card ic-card-interactive"
style="text-decoration: none; color: inherit; padding: 20px; display: flex; flex-direction: column; gap: 8px;"
>
<span style="font-size: 1.6rem;" aria-hidden="true">📋</span>
<strong style="font-size: 1rem;">Tous les conseils</strong>
<span style="color: var(--ink-soft); font-size: 0.88rem; line-height: 1.4;">7 phénomènes Vigilance — kits à imprimer et à partager</span>
</a>
<a
href="https://vigilance.meteofrance.fr/"
rel="noopener"
class="ic-card ic-card-interactive"
style="text-decoration: none; color: inherit; padding: 20px; display: flex; flex-direction: column; gap: 8px;"
>
<span style="font-size: 1.6rem;" aria-hidden="true">🔗</span>
<strong style="font-size: 1rem;">Source officielle</strong>
<span style="color: var(--ink-soft); font-size: 0.88rem; line-height: 1.4;">vigilance.meteofrance.fr — bulletin complet Météo France</span>
</a>
</div>
</section>
)}
<style>
.actions-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
details[open] summary span[aria-hidden] { transform: rotate(180deg); }
details summary::-webkit-details-marker { display: none; }
</style>
</Base>