info-canicule/src/pages/departement/[code].astro
Florian cb8d111a12
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
feat: normales journalières + dedupe SYNOP + carte plus large
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>
2026-05-26 01:43:10 +02:00

253 lines
9.5 KiB
Text

---
import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro';
import VigilanceLegend from '../../components/VigilanceLegend.astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../lib/vigilance';
import { getDepartement, isDrom } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL } 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 });
}
const drom = isDrom(dept.code);
let snapshot;
let error: string | null = null;
if (!drom) {
try {
snapshot = await getVigilanceSnapshot();
} catch (e) {
error = (e as Error).message;
}
}
let climato = null;
let anomaly = null;
let hourly = 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) {
try {
climato = await getClimatoForDepartement(dept.code);
if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days);
}
} catch (e) {
console.warn('climato fetch failed for', dept.code, (e as Error).message);
}
try {
hourly = await getHourlyForDepartement(dept.code, 24);
} catch (e) {
console.warn('hourly fetch failed for', dept.code, (e as Error).message);
}
}
const last7 = climato?.days?.slice(-7) ?? [];
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 today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
const tomorrow = snapshot ? alertsForDepartement(snapshot, dept.code, 'J1') : [];
const highest = today[0];
const adviceFor = highest && ADVICE[highest.phenomenonId];
---
<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.`}
>
<section class="bg-gradient-to-b from-canicule-50 to-white">
<div class="container-tight py-8">
<a href="/" class="text-sm text-canicule-700">← Retour à la carte</a>
<h1 class="mt-2 text-3xl font-bold sm:text-4xl">{dept.name}</h1>
<p class="text-slate-600">{dept.region} — département {dept.code}</p>
</div>
</section>
<section class="container-tight py-8">
{drom && (
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="font-semibold text-amber-900">Vigilance Outre-mer non couverte par cette source</p>
<p class="mt-1 text-sm text-amber-800">
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>
<p class="mt-3">
<a
href="https://vigilance.meteofrance.fr/"
rel="noopener"
class="inline-flex items-center gap-1 rounded bg-amber-700 px-4 py-2 text-sm font-semibold text-white no-underline hover:bg-amber-800"
>
Voir la Vigilance officielle Outre-mer →
</a>
</p>
</div>
)}
{!drom && error && <p class="text-red-700">Données indisponibles : {error}</p>}
{
!drom && !error && today.length === 0 && (
<div class="rounded border border-green-200 bg-green-50 p-4">
<p class="font-semibold text-green-800">Aucune vigilance particulière aujourd'hui.</p>
<p class="text-sm text-green-700">Le département est en niveau vert pour tous les phénomènes.</p>
</div>
)
}
{
!drom && !error && today.length > 0 && (
<>
<h2 class="mb-3 text-xl font-semibold">Alertes en cours</h2>
<ul class="space-y-3">
{today.map((a) => {
const phen = PHENOMENA[a.phenomenonId];
const begin = new Date(a.beginTime).toLocaleString('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'Europe/Paris',
});
const end = new Date(a.endTime).toLocaleString('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: 'Europe/Paris',
});
return (
<li class="rounded-lg border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-lg font-semibold">
<span aria-hidden="true">{phen.emoji}</span> {phen.label}
</div>
<VigilanceChip colorId={a.colorId} showLevel />
</div>
<div class="mt-2 text-sm text-slate-600">
Valide du {begin} au {end} (heure de Paris).
</div>
</li>
);
})}
</ul>
</>
)
}
{
!drom && !error && tomorrow.length > 0 && (
<div class="mt-8">
<h2 class="mb-3 text-xl font-semibold">Prévision pour demain</h2>
<ul class="space-y-2">
{tomorrow.map((a) => (
<li class="flex flex-wrap items-center justify-between gap-3 rounded border border-slate-200 px-3 py-2">
<span>{PHENOMENA[a.phenomenonId].label}</span>
<VigilanceChip colorId={a.colorId} />
</li>
))}
</ul>
</div>
)
}
</section>
{
climato && climato.days.length > 0 && (
<section class="border-t border-slate-200 bg-slate-50">
<div class="container-tight py-8">
<h2 class="mb-4 text-xl font-semibold">Températures récentes</h2>
{anomaly && (
<div class="mb-4">
<AnomalyBadge anomaly={anomaly} />
</div>
)}
<TemperatureChartInteractive
hourly={hourly}
days7={last7}
days30={last30}
normales7={normales7}
normales30={normales30}
normaleHourly={normaleHourly}
stationLabel={stationLabel}
/>
<p class="mt-2 text-xs text-slate-500">
Sources : Météo France — observations SYNOP horaires (onglet 24 h) et données
climatologiques de base quotidiennes (7 j / 30 j), agrégées par moyenne sur les
stations du département. Normales calculées sur 1991-2020 (référence WMO).
</p>
</div>
</section>
)
}
{
adviceFor && (
<section class="border-t border-slate-200 bg-white">
<div class="container-tight py-8">
<h2 class="text-xl font-semibold">Conseils — {PHENOMENA[highest!.phenomenonId].label}</h2>
<p class="mt-2 text-slate-700">{adviceFor.intro}</p>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
{adviceFor.blocks.map((block) => (
<div class="rounded-lg border border-slate-200 p-4">
<h3 class="font-semibold text-slate-900">{block.title}</h3>
<ul class="mt-2 list-inside list-disc space-y-1 text-sm text-slate-700">
{block.items.map((item) => (
<li>{item}</li>
))}
</ul>
</div>
))}
</div>
{adviceFor.emergency.length > 0 && (
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4">
<h3 class="font-semibold text-red-900">En cas d'urgence</h3>
<ul class="mt-2 space-y-1 text-sm text-red-800">
{adviceFor.emergency.map((e) => (
<li>{e}</li>
))}
</ul>
</div>
)}
<div class="mt-6">
<h3 class="text-sm font-semibold text-slate-700">Numéros utiles</h3>
<div class="mt-2 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
{EMERGENCY_NUMBERS.map((n) => (
<div class="flex items-baseline gap-2">
<a href={`tel:${n.num}`} class="font-mono font-bold text-canicule-700">
{n.num}
</a>
<span class="text-slate-600">{n.label}</span>
</div>
))}
</div>
</div>
</div>
</section>
)
}
</Base>