feat(design): refonte page département + AnomalyBadge, paliers soutenir
All checks were successful
Deploy info-canicule / deploy (push) Successful in 1m31s

- Page département : nouveau design issu du mockup hi-fi (hero v-block
  coloré, "Que faire ?", autres alertes compactes, section repliable
  "tous au vert", tableau aujourd'hui/demain, actions rapides)
- AnomalyBadge : refonte avec CSS tokens (v-block coloré, grand nombre,
  inline mode supprimé)
- /soutenir : 3 paliers progressifs (10 €, 40 €, 150 €) avec barres de
  progression individuelles et indicateur d'objectif actif

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-27 21:29:17 +02:00
parent d5c0b0968d
commit 003b49c297
3 changed files with 436 additions and 260 deletions

View file

@ -3,78 +3,81 @@ import type { Anomaly } from '../lib/normales';
interface Props { interface Props {
anomaly: Anomaly; anomaly: Anomaly;
inline?: boolean;
} }
const { anomaly } = Astro.props; const { anomaly, inline = false } = Astro.props;
const CATEGORY_LABEL: Record<Anomaly['txCategory'], { label: string; cls: string; icon: string }> = { // Map category to a v-block color class and display label
normal: { const CATEGORY_MAP: Record<Anomaly['txCategory'], { colorClass: string; label: string }> = {
label: 'Températures dans la normale saisonnière', normal: { colorClass: 'v-vert', label: 'Températures dans la normale saisonnière' },
cls: 'border-slate-200 bg-slate-50 text-slate-700', warm: { colorClass: 'v-jaune', label: 'Légèrement au-dessus de la normale' },
icon: '🌡️', cool: { colorClass: '', label: 'Légèrement en dessous de la normale' },
}, anomaly_warm: { colorClass: 'v-orange', label: 'Anormalement chaud' },
warm: { anomaly_cool: { colorClass: '', label: 'Anormalement frais' },
label: 'Légèrement au-dessus de la normale', extreme_warm: { colorClass: 'v-rouge', label: 'Extrêmement chaud — déviation extrême' },
cls: 'border-yellow-200 bg-yellow-50 text-yellow-900', extreme_cool: { colorClass: '', label: 'Extrêmement frais — déviation extrême' },
icon: '↗', unknown: { colorClass: '', label: 'Normale non disponible pour ce mois' },
},
cool: {
label: 'Légèrement en dessous de la normale',
cls: 'border-blue-200 bg-blue-50 text-blue-900',
icon: '↘',
},
anomaly_warm: {
label: 'Anormalement chaud',
cls: 'border-orange-300 bg-orange-50 text-orange-900',
icon: '🔥',
},
anomaly_cool: {
label: 'Anormalement frais',
cls: 'border-blue-300 bg-blue-100 text-blue-900',
icon: '❄',
},
extreme_warm: {
label: 'Extrêmement chaud (déviation extrême)',
cls: 'border-red-300 bg-red-50 text-red-900 font-semibold',
icon: '🚨',
},
extreme_cool: {
label: 'Extrêmement frais (déviation extrême)',
cls: 'border-blue-400 bg-blue-200 text-blue-900 font-semibold',
icon: '🚨',
},
unknown: {
label: 'Normale non disponible pour ce mois',
cls: 'border-slate-200 bg-slate-50 text-slate-500',
icon: '?',
},
}; };
const cat = CATEGORY_LABEL[anomaly.txCategory]; const cat = CATEGORY_MAP[anomaly.txCategory];
const signDiff = (anomaly.diffTx ?? 0) > 0 ? '+' : ''; const signDiff = (anomaly.diffTx ?? 0) > 0 ? '+' : '';
// CSS variable helpers — colorClass is 'v-vert' | 'v-jaune' | 'v-orange' | 'v-rouge' | ''
const inkVar = cat.colorClass ? `var(--${cat.colorClass}-ink)` : 'var(--ink)';
const ink2Var = cat.colorClass ? `var(--${cat.colorClass}-ink)` : 'var(--ink-2)';
const muteVar = cat.colorClass ? `var(--${cat.colorClass}-ink)` : 'var(--ink-soft)';
--- ---
<div class:list={['rounded-lg border p-4', cat.cls]}> {inline ? (
<div class="flex items-center gap-2 text-sm font-semibold"> <span
<span aria-hidden="true">{cat.icon}</span> class:list={[cat.colorClass ? `v-block ${cat.colorClass}` : '']}
<span>{cat.label}</span> style={cat.colorClass
? 'padding: 6px 12px; font-size: 0.82rem; font-weight: 600; display: inline-flex; align-items: center; gap: 6px;'
: 'font-size: 0.82rem; color: var(--ink-soft); display: inline-flex; align-items: center; gap: 6px;'}
title={cat.label}
>
{anomaly.diffTx !== null && (
<span style={`font-family: var(--font-mono); font-weight: 700; color: ${inkVar};`}>
{signDiff}{anomaly.diffTx}°C
</span>
)}
<span style={`color: ${inkVar};`}>{cat.label}</span>
</span>
) : (
<div
class:list={['v-block', cat.colorClass || 'ic-card-soft']}
style={!cat.colorClass ? 'background: var(--paper-warm); border-color: var(--line); padding: 20px 24px;' : 'padding: 20px 24px;'}
>
<div class="kicker" style={`opacity: 0.8; color: ${muteVar};`}>
Écart à la normale (19912020)
</div> </div>
{anomaly.diffTx !== null && anomaly.normaleTx !== null && ( <div style="display: flex; flex-wrap: wrap; gap: 28px; margin-top: 14px; align-items: baseline;">
<p class="mt-2 text-sm"> {anomaly.diffTx !== null ? (
<strong>T° max moyenne {anomaly.windowDays} derniers jours :</strong>{' '} <>
<span class="font-mono">{anomaly.meanTx}°C</span> <div>
<span class="text-slate-500"> · normale du mois (1991-2020) : <span class="font-mono">{anomaly.normaleTx}°C</span></span> <div style={`font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1.4rem + 2vw, 3rem); line-height: 1; letter-spacing: -0.025em; color: ${inkVar};`}>
</p> {signDiff}{anomaly.diffTx}°C
<p class="mt-1 text-sm"> </div>
<strong>Écart :</strong>{' '} <div style={`margin-top: 6px; font-size: 0.9rem; color: ${ink2Var};`}>
<span class="font-mono">{signDiff}{anomaly.diffTx}°C</span> {cat.label}
</div>
</div>
{anomaly.normaleTx !== null && (
<div style={`font-size: 0.92rem; line-height: 1.6; color: ${ink2Var};`}>
T° max moy. {anomaly.windowDays} derniers jours : <strong style="font-family: var(--font-mono);">{anomaly.meanTx}°C</strong><br/>
Normale du mois (19912020) : <strong style="font-family: var(--font-mono);">{anomaly.normaleTx}°C</strong>
{anomaly.sigmaTx !== null && ( {anomaly.sigmaTx !== null && (
<span class="text-xs text-slate-500"> ({anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ)</span> <><br/>Écart-type : <strong style="font-family: var(--font-mono);">{anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ</strong></>
)} )}
</p> </div>
)} )}
<p class="mt-2 text-xs text-slate-500"> </>
Comparaison sur la période de référence WMO 1991-2020. σ = écart-type, mesure de l'amplitude ) : (
historique du mois ; au-delà de 2σ l'événement est statistiquement rare. <p style={`color: ${muteVar}; font-size: 0.9rem;`}>{cat.label}</p>
)}
</div>
<p style={`margin-top: 12px; font-size: 0.8rem; opacity: 0.75; color: ${muteVar};`}>
Au-delà de 2σ, l'événement est statistiquement rare (référence WMO).
</p> </p>
</div> </div>
)}

View file

@ -1,10 +1,10 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro'; import VigilanceChip from '../../components/VigilanceChip.astro';
import VigilanceLegend from '../../components/VigilanceLegend.astro';
import { getVigilanceSnapshot, alertsForDepartement, currentEcheance } from '../../lib/vigilance'; import { getVigilanceSnapshot, alertsForDepartement, currentEcheance } from '../../lib/vigilance';
import { getDepartement, isDrom } from '../../lib/departements'; import { getDepartement, isDrom } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena'; import { PHENOMENA, COLOR_LABEL, COLORS } from '../../lib/phenomena';
import type { PhenomenonId, ColorId } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice'; import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
import { getClimatoForDepartement } from '../../lib/climato'; import { getClimatoForDepartement } from '../../lib/climato';
import { computeAnomaly, normaleForDate, normalesForRange } from '../../lib/normales'; import { computeAnomaly, normaleForDate, normalesForRange } from '../../lib/normales';
@ -25,8 +25,6 @@ Astro.response.headers.set('Cache-Control', 'public, max-age=60, must-revalidate
const drom = isDrom(dept.code); const drom = isDrom(dept.code);
// Fetch en parallèle des 3 sources externes (vigilance MF, climato data.gouv, hourly MF SYNOP).
// Cold-fetch climato = ~10s (4 MB de previous CSV.GZ), parallel ramène le total à max(individuel).
const [snapshotR, climatoR, hourlyR] = drom const [snapshotR, climatoR, hourlyR] = drom
? [Promise.resolve(null), Promise.resolve(null), Promise.resolve(null)] as const ? [Promise.resolve(null), Promise.resolve(null), Promise.resolve(null)] as const
: ([ : ([
@ -61,7 +59,6 @@ const last30 = climato?.days?.slice(-30) ?? [];
const last365 = climato?.days?.slice(-365) ?? []; const last365 = climato?.days?.slice(-365) ?? [];
if (!drom) { if (!drom) {
// Tous les lookups normales sont locaux (JSON statique) — pas besoin de paralléliser plus.
if (climato?.days?.length) { if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days); anomaly = await computeAnomaly(dept.code, climato.days);
} }
@ -79,134 +76,275 @@ if (!drom) {
const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null; const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
// "Aujourd'hui" = écheance courante (J ou J1 selon l'heure)
// "Demain" = J1 si on est encore sur J, sinon pas dispo (bulletin n'a pas J2)
const ech = snapshot ? currentEcheance(snapshot) : 'J'; const ech = snapshot ? currentEcheance(snapshot) : 'J';
const today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : []; const today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : [];
const tomorrow = (snapshot && ech === 'J') ? alertsForDepartement(snapshot, dept.code, 'J1') : []; const tomorrow = (snapshot && ech === 'J') ? alertsForDepartement(snapshot, dept.code, 'J1') : [];
const highest = today[0];
const adviceFor = highest && ADVICE[highest.phenomenonId];
const labelDate = (iso: string) => new Date(iso).toLocaleDateString('fr-FR', { // Sort by severity desc, pick top alert for the hero block
const sortedToday = [...today].sort((a, b) => b.colorId - a.colorId);
const topAlert = sortedToday[0] ?? null;
const otherAlerts = sortedToday.slice(1);
const adviceFor = topAlert ? ADVICE[topAlert.phenomenonId] : null;
// First 3 action items as quick bullets inside the hero block
const quickActions = adviceFor
? adviceFor.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: ech === 'J' ? ((tomorrowByPhenom.get(id) ?? 1) as ColorId) : null,
changed: ech === 'J' && (todayByPhenom.get(id) ?? 1) !== (tomorrowByPhenom.get(id) ?? 1),
}));
// Phenomena with no active alert today (green)
const activePhenomIds = new Set(today.map((a) => a.phenomenonId));
const inactivePhenomIds = 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', weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris',
}); });
const todayLabel = today[0] ? labelDate(today[0].beginTime) : ''; const todayLabel = today[0] ? labelDate(today[0].beginTime) : '';
const tomorrowLabel = tomorrow[0] ? labelDate(tomorrow[0].beginTime) : ''; const tomorrowLabel = tomorrow[0] ? labelDate(tomorrow[0].beginTime) : '';
// Glyphs matching the pill levels
const GLYPHS: Record<ColorId, string> = { 1: '●', 2: '▲', 3: '◆', 4: '■' };
// Pre-computed display values for the hero alert block
const topColorName = topAlert ? COLORS[topAlert.colorId].name : null;
const topPhen = topAlert ? PHENOMENA[topAlert.phenomenonId] : null;
--- ---
<Base <Base
title={`${dept.name} (${dept.code}) — Vigilance météo`} 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.`} 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"> {/* ── Hero header ── */}
<div class="container-tight py-8"> <section class="container-tight" style="padding-block: clamp(24px, 4vw, 48px) 20px;">
<a href="/" class="text-sm text-canicule-700">← Retour à la carte</a> <a href="/" class="ic-btn ic-btn-ghost ic-btn-sm" style="text-decoration: none; margin-bottom: 20px; display: inline-flex;">
<h1 class="mt-2 text-3xl font-bold sm:text-4xl">{dept.name}</h1> ← Retour à la carte
<p class="text-slate-600">{dept.region} — département {dept.code}</p> </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> </div>
</section> </section>
<section class="container-tight py-8"> {/* ── Vigilance status ── */}
<section class="container-tight" style="padding-block: 0 32px;">
{/* DROM notice */}
{drom && ( {drom && (
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4"> <div class="v-block v-jaune" style="margin-bottom: 24px;">
<p class="font-semibold text-amber-900">Vigilance Outre-mer non couverte par cette source</p> <strong style="color: var(--v-jaune-ink);">▲ Vigilance Outre-mer non couverte par cette source</strong>
<p class="mt-1 text-sm text-amber-800"> <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é 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) 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. rediffusées en open data au même format que la métropole.
</p> </p>
<p class="mt-3">
<a <a
href="https://vigilance.meteofrance.fr/" href="https://vigilance.meteofrance.fr/"
rel="noopener" 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" 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 → Voir la Vigilance officielle Outre-mer →
</a> </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> </p>
</div> </div>
)} )}
{!drom && error && <p class="text-red-700">Données indisponibles : {error}</p>} {!drom && !error && (
{
!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 {todayLabel ? `pour ${todayLabel}` : "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 capitalize"> {/* All-green state */}
Alertes en cours {todayLabel ? `— ${todayLabel}` : ''} {sortedToday.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> </h2>
<ul class="space-y-3"> <p style="color: var(--v-vert-ink); margin-top: 10px; line-height: 1.5;">
{today.map((a) => { Tous les phénomènes sont au vert pour {dept.name}. Restez attentif aux mises à jour Météo France.
</p>
</div>
)}
{/* Hero — top alert */}
{topAlert && topColorName && topPhen && (
<div class={`v-block v-${topColorName}`} 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">{topPhen.emoji}</span>
<h2 style={`color: var(--v-${topColorName}-ink); font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem);`}>
{GLYPHS[topAlert.colorId]} {topPhen.label} — {COLOR_LABEL[topAlert.colorId]}
</h2>
</div>
{adviceFor && (
<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((a) => <li>{a}</li>)}
</ul>
<a
href={`/conseils/${topPhen.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 {topPhen.label} →
</a>
</div>
)}
<p style={`color: var(--v-${topColorName}-ink); margin-top: 14px; font-size: 0.85rem; opacity: 0.85;`}>
Valide du {fmtTime(topAlert.beginTime)} au {fmtTime(topAlert.endTime)} — heure de Paris.
</p>
</div>
)}
{/* Other active alerts */}
{otherAlerts.map((a) => {
const colorName = COLORS[a.colorId].name;
const phen = PHENOMENA[a.phenomenonId]; 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 ( return (
<li class="rounded-lg border border-slate-200 bg-white p-4"> <div class={`v-block v-${colorName}`} style="margin-bottom: 12px; padding: 16px;">
<div class="flex flex-wrap items-center justify-between gap-3"> <div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
<div class="text-lg font-semibold"> <div style="display: flex; align-items: center; gap: 10px;">
<span aria-hidden="true">{phen.emoji}</span> {phen.label} <span style="font-size: 1.3rem;" aria-hidden="true">{phen.emoji}</span>
<strong style={`color: var(--v-${colorName}-ink);`}>{phen.label}</strong>
</div> </div>
<VigilanceChip colorId={a.colorId} showLevel /> <VigilanceChip colorId={a.colorId} showLevel />
</div> </div>
<div class="mt-2 text-sm text-slate-600"> <p style={`color: var(--v-${colorName}-ink); margin-top: 8px; font-size: 0.82rem; opacity: 0.8;`}>
Valide du {begin} au {end} (heure de Paris). Du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)}
</p>
</div> </div>
</li>
); );
})} })}
</ul>
</>
)
}
{ {/* Collapsible: inactive phenomena (all green) */}
!drom && !error && tomorrow.length > 0 && ( {inactivePhenomIds.length > 0 && (
<div class="mt-8"> <details class="ic-card" style="margin-bottom: 28px; padding: 16px;">
<h2 class="mb-3 text-xl font-semibold capitalize"> <summary style="cursor: pointer; font-weight: 600; color: var(--ink-2); display: flex; align-items: center; justify-content: space-between; list-style: none;">
Prévision {tomorrowLabel ? `— ${tomorrowLabel}` : 'pour demain'} <span>
</h2> {inactivePhenomIds.length} phénomène{inactivePhenomIds.length > 1 ? 's' : ''} — tous au vert
<ul class="space-y-2"> </span>
{tomorrow.map((a) => ( <span aria-hidden="true" style="color: var(--ink-mute); font-size: 0.8rem;">▼</span>
<li class="flex flex-wrap items-center justify-between gap-3 rounded border border-slate-200 px-3 py-2"> </summary>
<span>{PHENOMENA[a.phenomenonId].label}</span> <div class="grid gap-2" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 14px;">
<VigilanceChip colorId={a.colorId} /> {inactivePhenomIds.map((id) => (
</li> <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>
</ul> <span style="color: var(--v-vert-ink);">{PHENOMENA[id].label}</span>
</div> </div>
) ))}
} </div>
</section> </details>
)}
{ {/* Today vs Tomorrow comparison */}
climato && climato.days.length > 0 && ( {(today.length > 0 || tomorrow.length > 0 || ech === 'J') && (
<section class="border-t border-slate-200 bg-slate-50"> <div class="ic-card" style="margin-bottom: 28px; overflow-x: auto;">
<div class="container-tight py-8"> <h3 style="margin-bottom: 16px;">
<h2 class="mb-4 text-xl font-semibold">Températures récentes</h2> Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''}
{anomaly && ( </h3>
<div class="mb-4"> <div class="comparison-grid" style="display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 6px 12px; align-items: center; min-width: 360px;">
<AnomalyBadge anomaly={anomaly} /> <span class="kicker">Phénomène</span>
<span class="kicker" style="text-align: center;">Aujourd'hui</span>
{ech === 'J'
? <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> </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" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));">
{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 <TemperatureChartInteractive
hourly={hourly} hourly={hourly}
days7={last7} days7={last7}
@ -218,62 +356,61 @@ const tomorrowLabel = tomorrow[0] ? labelDate(tomorrow[0].beginTime) : '';
normaleHourly={normaleHourly} normaleHourly={normaleHourly}
stationLabel={stationLabel} stationLabel={stationLabel}
/> />
<p class="mt-2 text-xs text-slate-500"> {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 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 climatologiques de base quotidiennes (7 j / 30 j / 365 j), agrégées par station la plus
stations du département. Normales calculées sur 1991-2020 (référence WMO). proche. Normales 1991-2020 (référence WMO).
</p> </p>
</div> </div>
</section> </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"> {/* ── Actions rapides ── */}
<h3 class="text-sm font-semibold text-slate-700">Numéros utiles</h3> {!drom && !error && (
<div class="mt-2 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"> <section class="container-tight" style="padding-block: 0 32px;">
{EMERGENCY_NUMBERS.map((n) => ( <div class="grid gap-3 actions-grid">
<div class="flex items-baseline gap-2"> {topAlert && (
<a href={`tel:${n.num}`} class="font-mono font-bold text-canicule-700"> <a
{n.num} 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> </a>
<span class="text-slate-600">{n.label}</span>
</div>
))}
</div>
</div>
</div> </div>
</section> </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> </Base>

View file

@ -3,11 +3,31 @@ import Base from '../layouts/Base.astro';
export const prerender = false; export const prerender = false;
const goalLabel = "Location d'un nom de domaine dédié";
const goalTarget = 10;
const goalRaised = 0; const goalRaised = 0;
const goalCurrency = '€'; const goalCurrency = '€';
const goalPercent = Math.min(100, Math.round((goalRaised / goalTarget) * 100));
const goals = [
{
label: "Nom de domaine dédié",
target: 10,
desc: "Réserver info-canicule.fr pour un an.",
},
{
label: "Frais d'hébergement",
target: 40,
desc: "Contribuer à un mois de VPS OVH (~30 €/mois).",
},
{
label: "Tous les frais engendrés",
target: 150,
desc: "Couvrir hébergement, domaine et frais divers sur plusieurs mois.",
},
];
// Index of the first goal not yet fully funded
const activeGoalIdx = goals.findIndex((g) => goalRaised < g.target);
const activeGoal = activeGoalIdx >= 0 ? goals[activeGoalIdx] : goals[goals.length - 1];
const goalPercent = Math.min(100, Math.round((goalRaised / activeGoal.target) * 100));
const usages = [ const usages = [
{ title: "Hébergement", text: "VPS mutualisé chez OVH : ~30 € / mois pour faire tourner le site, le cache Valkey et le pipeline de données." }, { title: "Hébergement", text: "VPS mutualisé chez OVH : ~30 € / mois pour faire tourner le site, le cache Valkey et le pipeline de données." },
@ -36,31 +56,47 @@ const otherWays = [
</p> </p>
</div> </div>
{/* Objectif de don */} {/* Paliers de don */}
<div class="ic-card" style="padding: 22px; margin-bottom: 24px; border-color: var(--brand); background: var(--brand-tint);"> <div style="margin-bottom: 24px; display: flex; flex-direction: column; gap: 12px;">
<div class="flex items-baseline justify-between gap-3" style="flex-wrap: wrap;"> <div class="kicker" style="color: var(--brand-deep); margin-bottom: 4px;">Objectifs de don</div>
{goals.map((g, i) => {
const isActive = i === activeGoalIdx;
const isDone = goalRaised >= g.target;
const pct = isDone ? 100 : (i < activeGoalIdx ? 100 : goalPercent);
return (
<div
class="ic-card"
style={isActive
? "padding: 20px; border-color: var(--brand); background: var(--brand-tint);"
: "padding: 16px 20px; opacity: " + (isDone ? "1" : "0.65") + ";"}
>
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
<div> <div>
<div class="kicker" style="color: var(--brand-deep);">Objectif de don en cours</div> {isDone && <span style="color: var(--v-vert); font-weight: 700; margin-right: 8px;" aria-hidden="true">✓</span>}
<p style="margin-top: 6px; font-weight: 600; color: var(--brand-ink); font-size: 1.05rem;">{goalLabel}</p> {isActive && <span style="color: var(--brand); font-weight: 700; margin-right: 8px;" aria-hidden="true">→</span>}
<strong style={isActive ? "color: var(--brand-ink); font-size: 1.02rem;" : ""}>{g.label}</strong>
<span style="color: var(--ink-soft); font-size: 0.88rem; margin-left: 8px;">{g.desc}</span>
</div> </div>
<p style="flex-shrink: 0; font-size: 0.95rem; font-variant-numeric: tabular-nums; color: var(--ink-2);"> <span style="flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 0.95rem; color: var(--ink-2);">
<span style="font-weight: 700; color: var(--brand-ink); font-family: var(--font-display); font-size: 1.15rem;">{goalRaised} {goalCurrency}</span> <span style={`font-weight: 700; font-family: var(--font-display); font-size: ${isActive ? '1.1rem' : '1rem'}; color: ${isActive ? 'var(--brand-ink)' : 'inherit'};`}>
<span style="color: var(--ink-mute);"> / {goalTarget} {goalCurrency}</span> {goalRaised} {goalCurrency}
</p> </span>
<span style="color: var(--ink-mute);"> / {g.target} {goalCurrency}</span>
</span>
</div> </div>
<div <div
style="margin-top: 14px; height: 10px; width: 100%; overflow: hidden; border-radius: 999px; background: rgba(180, 90, 58, 0.15);" style="margin-top: 12px; height: 8px; width: 100%; overflow: hidden; border-radius: 999px; background: rgba(180, 90, 58, 0.12);"
role="progressbar" role="progressbar"
aria-valuenow={goalPercent} aria-valuenow={pct}
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
aria-label={`${goalPercent}% de l'objectif atteint`} aria-label={`${pct}% de l'objectif « ${g.label} » atteint`}
> >
<div style={`height: 100%; width: ${goalPercent}%; background: var(--brand); transition: width .3s;`}></div> <div style={`height: 100%; width: ${pct}%; background: ${isDone ? 'var(--v-vert)' : 'var(--brand)'}; transition: width .3s;`}></div>
</div> </div>
<p style="margin-top: 10px; font-size: 0.85rem; color: var(--brand-ink);"> </div>
{goalPercent}% atteint — une fois l'objectif rempli, le nom de domaine dédié sera réservé pour un an. );
</p> })}
</div> </div>
{/* CTA principal */} {/* CTA principal */}