Some checks failed
Deploy info-canicule / deploy (push) Failing after 5s
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>
471 lines
23 KiB
Text
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>
|