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>
253 lines
9.5 KiB
Text
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>
|