info-canicule/src/pages/departement/[code].astro
Florian c170bbca7d
Some checks failed
Deploy info-canicule / deploy (push) Failing after 5s
fix(dept): vert ≠ vigilance, conseils sur chaque alerte active
Le flux Météo France renvoie parfois des phénomènes explicitement au vert
(colorId 1) : ils étaient traités comme des alertes, d'où un « Que faire
maintenant ? » affiché sur un phénomène vert et un dropdown limité aux
phénomènes absents du flux.

- "Active" = vigilance jaune ou plus (colorId >= 2) ; le vert n'est jamais
  une alerte
- Tout au vert → un seul encadré « Aucune vigilance active »
- Dropdown regroupe désormais tous les phénomènes au vert (présents au vert
  dans le flux + absents)
- Une vigilance présente → bloc complet « Que faire maintenant ? » sur
  chacune des alertes actives (au lieu de la première seulement)
- Tableau Aujourd'hui vs Demain et suite : inchangés

Au passage, fix typage FranceMap : Map par défaut typée pour ne pas perdre
VigilanceAlert (5 erreurs astro check), imports inutilisés retirés.

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

471 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. "Active" = vigilance jaune ou plus (colorId >= 2).
// Le flux MF renvoie parfois des phénomènes explicitement au vert (colorId 1) —
// ce ne sont PAS des alertes : ils rejoignent le dropdown "tous au vert", au même
// titre que les phénomènes absents du flux. topAlert = la plus sévère des actives
// (sert aux numéros d'urgence + carte "Kit" en bas de page).
const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId);
const activeToday = sortedToday.filter((a) => a.colorId >= 2);
const topAlert = activeToday[0] ?? null;
// First 3 action items, par phénomène, pour le bloc "Que faire maintenant ?"
function quickActionsFor(phenId: PhenomenonId): string[] {
const advice = ADVICE[phenId];
return advice ? advice.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),
}));
// Phénomènes au vert aujourd'hui = ceux sans alerte active (jaune+). Inclut aussi
// les phénomènes absents du flux MF (réputés verts). Tous regroupés dans le dropdown.
const activePhenomIds = new Set(activeToday.map((a) => a.phenomenonId));
const greenPhenomIds = 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: '■' };
---
<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 — aucune vigilance active (jaune+) */}
{activeToday.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} — rien de particulier à signaler.
Restez attentif aux mises à jour Météo France.
</p>
</div>
)}
{/* Active alerts (jaune+) — bloc complet "Que faire maintenant ?" sur chacune */}
{activeToday.map((a) => {
const colorName = COLORS[a.colorId].name;
const phen = PHENOMENA[a.phenomenonId];
const advice = ADVICE[a.phenomenonId];
const quickActions = quickActionsFor(a.phenomenonId);
return (
<div class={`v-block v-${colorName}`} 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">{phen.emoji}</span>
<h2 style={`color: var(--v-${colorName}-ink); font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem);`}>
{GLYPHS[a.colorId]} {phen.label} — {COLOR_LABEL[a.colorId]}
</h2>
</div>
{advice && (
<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((item) => <li>{item}</li>)}
</ul>
<a
href={`/conseils/${phen.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 {phen.label} →
</a>
</div>
)}
<p style={`color: var(--v-${colorName}-ink); margin-top: 14px; font-size: 0.85rem; opacity: 0.85;`}>
Valide du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)} — heure de Paris.
</p>
</div>
);
})}
{/* Collapsible: phénomènes au vert (sans vigilance) */}
{greenPhenomIds.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>
{greenPhenomIds.length} phénomène{greenPhenomIds.length > 1 ? 's' : ''} au vert — aucune vigilance
</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;">
{greenPhenomIds.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>