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 {
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 }> = {
normal: {
label: 'Températures dans la normale saisonnière',
cls: 'border-slate-200 bg-slate-50 text-slate-700',
icon: '🌡️',
},
warm: {
label: 'Légèrement au-dessus de la normale',
cls: 'border-yellow-200 bg-yellow-50 text-yellow-900',
icon: '↗',
},
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: '?',
},
// Map category to a v-block color class and display label
const CATEGORY_MAP: Record<Anomaly['txCategory'], { colorClass: string; label: string }> = {
normal: { colorClass: 'v-vert', label: 'Températures dans la normale saisonnière' },
warm: { colorClass: 'v-jaune', label: 'Légèrement au-dessus de la normale' },
cool: { colorClass: '', label: 'Légèrement en dessous de la normale' },
anomaly_warm: { colorClass: 'v-orange', label: 'Anormalement chaud' },
anomaly_cool: { colorClass: '', label: 'Anormalement frais' },
extreme_warm: { colorClass: 'v-rouge', label: 'Extrêmement chaud — déviation extrême' },
extreme_cool: { colorClass: '', label: 'Extrêmement frais — déviation extrême' },
unknown: { colorClass: '', label: 'Normale non disponible pour ce mois' },
};
const cat = CATEGORY_LABEL[anomaly.txCategory];
const cat = CATEGORY_MAP[anomaly.txCategory];
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]}>
<div class="flex items-center gap-2 text-sm font-semibold">
<span aria-hidden="true">{cat.icon}</span>
<span>{cat.label}</span>
{inline ? (
<span
class:list={[cat.colorClass ? `v-block ${cat.colorClass}` : '']}
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>
{anomaly.diffTx !== null && anomaly.normaleTx !== null && (
<p class="mt-2 text-sm">
<strong>T° max moyenne {anomaly.windowDays} derniers jours :</strong>{' '}
<span class="font-mono">{anomaly.meanTx}°C</span>
<span class="text-slate-500"> · normale du mois (1991-2020) : <span class="font-mono">{anomaly.normaleTx}°C</span></span>
</p>
<p class="mt-1 text-sm">
<strong>Écart :</strong>{' '}
<span class="font-mono">{signDiff}{anomaly.diffTx}°C</span>
<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 && (
<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>
</div>
</div>
)}

View file

@ -1,10 +1,10 @@
---
import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro';
import VigilanceLegend from '../../components/VigilanceLegend.astro';
import { getVigilanceSnapshot, alertsForDepartement, currentEcheance } from '../../lib/vigilance';
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 { getClimatoForDepartement } from '../../lib/climato';
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);
// 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
? [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) ?? [];
if (!drom) {
// Tous les lookups normales sont locaux (JSON statique) — pas besoin de paralléliser plus.
if (climato?.days?.length) {
anomaly = await computeAnomaly(dept.code, climato.days);
}
@ -79,134 +76,275 @@ if (!drom) {
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 today = snapshot ? alertsForDepartement(snapshot, dept.code, ech) : [];
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',
});
});
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: '■' };
// 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
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>
{/* ── 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>
<section class="container-tight py-8">
{/* ── Vigilance status ── */}
<section class="container-tight" style="padding-block: 0 32px;">
{/* DROM notice */}
{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">
<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>
<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"
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 && <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 {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 && (
{!drom && !error && (
<>
<h2 class="mb-3 text-xl font-semibold capitalize">
Alertes en cours {todayLabel ? `— ${todayLabel}` : ''}
{/* All-green state */}
{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>
<ul class="space-y-3">
{today.map((a) => {
<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}. 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 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 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>
<div class="mt-2 text-sm text-slate-600">
Valide du {begin} au {end} (heure de Paris).
<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>
</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>
{/* 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>
)
}
</section>
))}
</div>
</details>
)}
{
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} />
{/* 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) => (
<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={hourly}
days7={last7}
@ -218,62 +356,61 @@ const tomorrowLabel = tomorrow[0] ? labelDate(tomorrow[0].beginTime) : '';
normaleHourly={normaleHourly}
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
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).
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>
)
}
{
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}
{/* ── 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>
<span class="text-slate-600">{n.label}</span>
</div>
))}
</div>
</div>
</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>

View file

@ -3,11 +3,31 @@ import Base from '../layouts/Base.astro';
export const prerender = false;
const goalLabel = "Location d'un nom de domaine dédié";
const goalTarget = 10;
const goalRaised = 0;
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 = [
{ 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>
</div>
{/* Objectif de don */}
<div class="ic-card" style="padding: 22px; margin-bottom: 24px; border-color: var(--brand); background: var(--brand-tint);">
<div class="flex items-baseline justify-between gap-3" style="flex-wrap: wrap;">
{/* Paliers de don */}
<div style="margin-bottom: 24px; display: flex; flex-direction: column; gap: 12px;">
<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 class="kicker" style="color: var(--brand-deep);">Objectif de don en cours</div>
<p style="margin-top: 6px; font-weight: 600; color: var(--brand-ink); font-size: 1.05rem;">{goalLabel}</p>
{isDone && <span style="color: var(--v-vert); font-weight: 700; margin-right: 8px;" aria-hidden="true">✓</span>}
{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>
<p style="flex-shrink: 0; font-size: 0.95rem; font-variant-numeric: tabular-nums; 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="color: var(--ink-mute);"> / {goalTarget} {goalCurrency}</span>
</p>
<span style="flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 0.95rem; color: var(--ink-2);">
<span style={`font-weight: 700; font-family: var(--font-display); font-size: ${isActive ? '1.1rem' : '1rem'}; color: ${isActive ? 'var(--brand-ink)' : 'inherit'};`}>
{goalRaised} {goalCurrency}
</span>
<span style="color: var(--ink-mute);"> / {g.target} {goalCurrency}</span>
</span>
</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"
aria-valuenow={goalPercent}
aria-valuenow={pct}
aria-valuemin="0"
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>
<p style="margin-top: 10px; font-size: 0.85rem; color: var(--brand-ink);">
{goalPercent}% atteint — une fois l'objectif rempli, le nom de domaine dédié sera réservé pour un an.
</p>
</div>
);
})}
</div>
{/* CTA principal */}