--- 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> | null; else error = (settled[0].reason as Error).message; if (settled[1].status === 'fulfilled') climato = settled[1].value as Awaited> | 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> | 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(); if (hourly) { const _byDate = new Map(); 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(); 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(); 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 = { 1: '●', 2: '▲', 3: '◆', 4: '■' }; --- {/* ── Hero header ── */}
← Retour à la carte
{dept.region} · département {dept.code}

{dept.name}

{productDate && (

Bulletin Météo France du {productDate}

)}
{/* ── Vigilance status ── */}
{/* DROM notice */} {drom && (
▲ Vigilance Outre-mer non couverte par cette source

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.

Voir la Vigilance officielle Outre-mer →
)} {/* Error */} {!drom && error && (
■ Données momentanément indisponibles

Réessayez dans quelques minutes. En urgence, consultez vigilance.meteofrance.fr.

)} {!drom && !error && ( <> {/* All-green state — aucune vigilance active (jaune+) */} {activeToday.length === 0 && (

● Aucune vigilance active {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"}

Tous les phénomènes sont au vert pour {dept.name} — rien de particulier à signaler. Restez attentif aux mises à jour Météo France.

)} {/* 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 (

{GLYPHS[a.colorId]} {phen.label} — {COLOR_LABEL[a.colorId]}

{advice && (
Que faire maintenant ?
    {quickActions.map((item) =>
  • {item}
  • )}
Voir le kit complet {phen.label} →
)}

Valide du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)} — heure de Paris.

); })} {/* Collapsible: phénomènes au vert (sans vigilance) */} {greenPhenomIds.length > 0 && (
{greenPhenomIds.length} phénomène{greenPhenomIds.length > 1 ? 's' : ''} au vert — aucune vigilance
{greenPhenomIds.map((id) => (
{PHENOMENA[id].label}
))}
)} {/* Today vs Tomorrow comparison */} {(today.length > 0 || tomorrow.length > 0 || j1Available) && (

Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''}

Phénomène Aujourd'hui {j1Available ? Demain : Demain } {comparisonRows.map(({ id, phen, todayColor, tomorrowColor, changed }) => ( <> {phen.label} {tomorrowColor !== null ? ( {changed && change} ) : } ))}
)} {/* Emergency numbers if there's an active alert */} {topAlert && EMERGENCY_NUMBERS.length > 0 && (
Numéros d'urgence
{EMERGENCY_NUMBERS.map((n) => (
{n.num} {n.label}
))}
)} )}
{/* ── Température & climato ── */} {climato && climato.days.length > 0 && (

Températures récentes

{anomaly && (
)}

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).

)} {/* ── Actions rapides ── */} {!drom && !error && (
)}