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
</div> ? 'padding: 6px 12px; font-size: 0.82rem; font-weight: 600; display: inline-flex; align-items: center; gap: 6px;'
{anomaly.diffTx !== null && anomaly.normaleTx !== null && ( : 'font-size: 0.82rem; color: var(--ink-soft); display: inline-flex; align-items: center; gap: 6px;'}
<p class="mt-2 text-sm"> title={cat.label}
<strong>T° max moyenne {anomaly.windowDays} derniers jours :</strong>{' '} >
<span class="font-mono">{anomaly.meanTx}°C</span> {anomaly.diffTx !== null && (
<span class="text-slate-500"> · normale du mois (1991-2020) : <span class="font-mono">{anomaly.normaleTx}°C</span></span> <span style={`font-family: var(--font-mono); font-weight: 700; color: ${inkVar};`}>
</p> {signDiff}{anomaly.diffTx}°C
<p class="mt-1 text-sm"> </span>
<strong>Écart :</strong>{' '} )}
<span class="font-mono">{signDiff}{anomaly.diffTx}°C</span> <span style={`color: ${inkVar};`}>{cat.label}</span>
{anomaly.sigmaTx !== null && ( </span>
<span class="text-xs text-slate-500"> ({anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ)</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 style="display: flex; flex-wrap: wrap; gap: 28px; margin-top: 14px; align-items: baseline;">
{anomaly.diffTx !== null ? (
<>
<div>
<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};`}>
{signDiff}{anomaly.diffTx}°C
</div>
<div style={`margin-top: 6px; font-size: 0.9rem; color: ${ink2Var};`}>
{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 && (
<><br/>Écart-type : <strong style="font-family: var(--font-mono);">{anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ</strong></>
)}
</div>
)}
</>
) : (
<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>
<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>
</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,201 +76,341 @@ 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
weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris', 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',
});
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="ic-btn ic-btn-sm"
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" 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 && (
<>
{ {/* All-green state */}
!drom && !error && today.length === 0 && ( {sortedToday.length === 0 && (
<div class="rounded border border-green-200 bg-green-50 p-4"> <div class="v-block v-vert" style="margin-bottom: 24px;">
<p class="font-semibold text-green-800"> <h2 style="color: var(--v-vert-ink); font-size: clamp(1.2rem, 1rem + 0.8vw, 1.6rem);">
Aucune vigilance particulière {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"}. ● Aucune vigilance active {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"}
</p> </h2>
<p class="text-sm text-green-700">Le département est en niveau vert pour tous les phénomènes.</p> <p style="color: var(--v-vert-ink); margin-top: 10px; line-height: 1.5;">
</div> Tous les phénomènes sont au vert pour {dept.name}. Restez attentif aux mises à jour Météo France.
) </p>
}
{
!drom && !error && today.length > 0 && (
<>
<h2 class="mb-3 text-xl font-semibold capitalize">
Alertes en cours {todayLabel ? `— ${todayLabel}` : ''}
</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 capitalize">
Prévision {tomorrowLabel ? `— ${tomorrowLabel}` : '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}
days365={last365}
normales7={normales7}
normales30={normales30}
normales365={normales365}
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> </div>
)}
{adviceFor.emergency.length > 0 && ( {/* Hero — top alert */}
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4"> {topAlert && topColorName && topPhen && (
<h3 class="font-semibold text-red-900">En cas d'urgence</h3> <div class={`v-block v-${topColorName}`} style="margin-bottom: 24px;">
<ul class="mt-2 space-y-1 text-sm text-red-800"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap;">
{adviceFor.emergency.map((e) => ( <span style="font-size: 2rem; line-height: 1;" aria-hidden="true">{topPhen.emoji}</span>
<li>{e}</li> <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]}
</ul> </h2>
</div> </div>
)}
<div class="mt-6"> {adviceFor && (
<h3 class="text-sm font-semibold text-slate-700">Numéros utiles</h3> <div style="margin-top: 4px; padding: 16px 20px; background: var(--paper-2); border-radius: var(--r-md); color: var(--ink);">
<div class="mt-2 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3"> <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];
return (
<div class={`v-block v-${colorName}`} style="margin-bottom: 12px; padding: 16px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.3rem;" aria-hidden="true">{phen.emoji}</span>
<strong style={`color: var(--v-${colorName}-ink);`}>{phen.label}</strong>
</div>
<VigilanceChip colorId={a.colorId} showLevel />
</div>
<p style={`color: var(--v-${colorName}-ink); margin-top: 8px; font-size: 0.82rem; opacity: 0.8;`}>
Du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)}
</p>
</div>
);
})}
{/* Collapsible: inactive phenomena (all green) */}
{inactivePhenomIds.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>
{inactivePhenomIds.length} phénomène{inactivePhenomIds.length > 1 ? 's' : ''} — tous au vert
</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;">
{inactivePhenomIds.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 || ech === 'J') && (
<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>
{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>
)}
{/* 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) => ( {EMERGENCY_NUMBERS.map((n) => (
<div class="flex items-baseline gap-2"> <div style="display: flex; align-items: baseline; gap: 8px;">
<a href={`tel:${n.num}`} class="font-mono font-bold text-canicule-700"> <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} {n.num}
</a> </a>
<span class="text-slate-600">{n.label}</span> <span style="color: var(--ink-soft); font-size: 0.88rem;">{n.label}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> )}
</section> </>
) )}
} </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={hourly}
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> </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>
<div> {goals.map((g, i) => {
<div class="kicker" style="color: var(--brand-deep);">Objectif de don en cours</div> const isActive = i === activeGoalIdx;
<p style="margin-top: 6px; font-weight: 600; color: var(--brand-ink); font-size: 1.05rem;">{goalLabel}</p> const isDone = goalRaised >= g.target;
</div> const pct = isDone ? 100 : (i < activeGoalIdx ? 100 : goalPercent);
<p style="flex-shrink: 0; font-size: 0.95rem; font-variant-numeric: tabular-nums; color: var(--ink-2);"> return (
<span style="font-weight: 700; color: var(--brand-ink); font-family: var(--font-display); font-size: 1.15rem;">{goalRaised} {goalCurrency}</span> <div
<span style="color: var(--ink-mute);"> / {goalTarget} {goalCurrency}</span> class="ic-card"
</p> style={isActive
</div> ? "padding: 20px; border-color: var(--brand); background: var(--brand-tint);"
<div : "padding: 16px 20px; opacity: " + (isDone ? "1" : "0.65") + ";"}
style="margin-top: 14px; height: 10px; width: 100%; overflow: hidden; border-radius: 999px; background: rgba(180, 90, 58, 0.15);" >
role="progressbar" <div style="display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
aria-valuenow={goalPercent} <div>
aria-valuemin="0" {isDone && <span style="color: var(--v-vert); font-weight: 700; margin-right: 8px;" aria-hidden="true">✓</span>}
aria-valuemax="100" {isActive && <span style="color: var(--brand); font-weight: 700; margin-right: 8px;" aria-hidden="true">→</span>}
aria-label={`${goalPercent}% de l'objectif atteint`} <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 style={`height: 100%; width: ${goalPercent}%; background: var(--brand); transition: width .3s;`}></div> </div>
</div> <span style="flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 0.95rem; color: var(--ink-2);">
<p style="margin-top: 10px; font-size: 0.85rem; color: var(--brand-ink);"> <span style={`font-weight: 700; font-family: var(--font-display); font-size: ${isActive ? '1.1rem' : '1rem'}; color: ${isActive ? 'var(--brand-ink)' : 'inherit'};`}>
{goalPercent}% atteint — une fois l'objectif rempli, le nom de domaine dédié sera réservé pour un an. {goalRaised} {goalCurrency}
</p> </span>
<span style="color: var(--ink-mute);"> / {g.target} {goalCurrency}</span>
</span>
</div>
<div
style="margin-top: 12px; height: 8px; width: 100%; overflow: hidden; border-radius: 999px; background: rgba(180, 90, 58, 0.12);"
role="progressbar"
aria-valuenow={pct}
aria-valuemin="0"
aria-valuemax="100"
aria-label={`${pct}% de l'objectif « ${g.label} » atteint`}
>
<div style={`height: 100%; width: ${pct}%; background: ${isDone ? 'var(--v-vert)' : 'var(--brand)'}; transition: width .3s;`}></div>
</div>
</div>
);
})}
</div> </div>
{/* CTA principal */} {/* CTA principal */}