From 003b49c2973598f6b62b68a6108a2e86b046f197 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 27 May 2026 21:29:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(design):=20refonte=20page=20d=C3=A9parteme?= =?UTF-8?q?nt=20+=20AnomalyBadge,=20paliers=20soutenir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/AnomalyBadge.astro | 133 ++++---- src/pages/departement/[code].astro | 471 +++++++++++++++++++---------- src/pages/soutenir.astro | 92 ++++-- 3 files changed, 436 insertions(+), 260 deletions(-) diff --git a/src/components/AnomalyBadge.astro b/src/components/AnomalyBadge.astro index 51f03a1..ed1b9a6 100644 --- a/src/components/AnomalyBadge.astro +++ b/src/components/AnomalyBadge.astro @@ -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 = { - 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 = { + 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)'; --- -
-
- - {cat.label} -
- {anomaly.diffTx !== null && anomaly.normaleTx !== null && ( -

- T° max moyenne {anomaly.windowDays} derniers jours :{' '} - {anomaly.meanTx}°C - · normale du mois (1991-2020) : {anomaly.normaleTx}°C -

-

- Écart :{' '} - {signDiff}{anomaly.diffTx}°C - {anomaly.sigmaTx !== null && ( - ({anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ) +{inline ? ( + + {anomaly.diffTx !== null && ( + + {signDiff}{anomaly.diffTx}°C + + )} + {cat.label} + +) : ( +

+
+ Écart à la normale (1991–2020) +
+
+ {anomaly.diffTx !== null ? ( + <> +
+
+ {signDiff}{anomaly.diffTx}°C +
+
+ {cat.label} +
+
+ {anomaly.normaleTx !== null && ( +
+ T° max moy. {anomaly.windowDays} derniers jours : {anomaly.meanTx}°C
+ Normale du mois (1991–2020) : {anomaly.normaleTx}°C + {anomaly.sigmaTx !== null && ( + <>
Écart-type : {anomaly.sigmaTx > 0 ? '+' : ''}{anomaly.sigmaTx}σ + )} +
+ )} + + ) : ( +

{cat.label}

)} +
+

+ Au-delà de 2σ, l'événement est statistiquement rare (référence WMO).

- )} -

- 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. -

-
+
+)} diff --git a/src/pages/departement/[code].astro b/src/pages/departement/[code].astro index ab3b2c4..1903716 100644 --- a/src/pages/departement/[code].astro +++ b/src/pages/departement/[code].astro @@ -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,201 +76,341 @@ 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', { - weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Europe/Paris', -}); +// 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(); +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(); +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 = { 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; --- -
-
- ← Retour à la carte -

{dept.name}

-

{dept.region} — département {dept.code}

+ {/* ── Hero header ── */} +
+ + ← Retour à la carte + +
+
{dept.region} · département {dept.code}
+

{dept.name}

+ {productDate && ( +

+ Bulletin Météo France du {productDate} +

+ )}
-
+ {/* ── Vigilance status ── */} +
+ + {/* DROM notice */} {drom && ( -
-

Vigilance Outre-mer non couverte par cette source

-

+

+ ▲ Vigilance Outre-mer non couverte par cette source +

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.

-

- - Voir la Vigilance officielle Outre-mer → - + + Voir la Vigilance officielle Outre-mer → + +

+ )} + + {/* Error */} + {!drom && error && ( +
+ ■ Données momentanément indisponibles +

+ Réessayez dans quelques minutes. En urgence, consultez + vigilance.meteofrance.fr.

)} - {!drom && error &&

Données indisponibles : {error}

} - - { - !drom && !error && today.length === 0 && ( -
-

- Aucune vigilance particulière {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"}. -

-

Le département est en niveau vert pour tous les phénomènes.

-
- ) - } - - { - !drom && !error && today.length > 0 && ( - <> -

- Alertes en cours {todayLabel ? `— ${todayLabel}` : ''} -

-
    - {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 ( -
  • -
    -
    - {phen.label} -
    - -
    -
    - Valide du {begin} au {end} (heure de Paris). -
    -
  • - ); - })} -
- - ) - } - - { - !drom && !error && tomorrow.length > 0 && ( -
-

- Prévision {tomorrowLabel ? `— ${tomorrowLabel}` : 'pour demain'} -

-
    - {tomorrow.map((a) => ( -
  • - {PHENOMENA[a.phenomenonId].label} - -
  • - ))} -
-
- ) - } -
- - { - climato && climato.days.length > 0 && ( -
-
-

Températures récentes

- {anomaly && ( -
- -
- )} - -

- 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). -

-
-
- ) - } - - { - adviceFor && ( -
-
-

Conseils — {PHENOMENA[highest!.phenomenonId].label}

-

{adviceFor.intro}

- -
- {adviceFor.blocks.map((block) => ( -
-

{block.title}

-
    - {block.items.map((item) => ( -
  • {item}
  • - ))} -
-
- ))} + {!drom && !error && ( + <> + {/* All-green state */} + {sortedToday.length === 0 && ( +
+

+ ● Aucune vigilance active {todayLabel ? `pour ${todayLabel}` : "aujourd'hui"} +

+

+ Tous les phénomènes sont au vert pour {dept.name}. Restez attentif aux mises à jour Météo France. +

+ )} - {adviceFor.emergency.length > 0 && ( -
-

En cas d'urgence

-
    - {adviceFor.emergency.map((e) => ( -
  • {e}
  • - ))} -
+ {/* Hero — top alert */} + {topAlert && topColorName && topPhen && ( +
+
+ +

+ {GLYPHS[topAlert.colorId]} {topPhen.label} — {COLOR_LABEL[topAlert.colorId]} +

- )} -
-

Numéros utiles

-
+ {adviceFor && ( +
+ Que faire maintenant ? +
    + {quickActions.map((a) =>
  • {a}
  • )} +
+ + Voir le kit complet {topPhen.label} → + +
+ )} + +

+ Valide du {fmtTime(topAlert.beginTime)} au {fmtTime(topAlert.endTime)} — heure de Paris. +

+
+ )} + + {/* Other active alerts */} + {otherAlerts.map((a) => { + const colorName = COLORS[a.colorId].name; + const phen = PHENOMENA[a.phenomenonId]; + return ( +
+
+
+ + {phen.label} +
+ +
+

+ Du {fmtTime(a.beginTime)} au {fmtTime(a.endTime)} +

+
+ ); + })} + + {/* Collapsible: inactive phenomena (all green) */} + {inactivePhenomIds.length > 0 && ( +
+ + + {inactivePhenomIds.length} phénomène{inactivePhenomIds.length > 1 ? 's' : ''} — tous au vert + + + +
+ {inactivePhenomIds.map((id) => ( +
+ + {PHENOMENA[id].label} +
+ ))} +
+
+ )} + + {/* Today vs Tomorrow comparison */} + {(today.length > 0 || tomorrow.length > 0 || ech === 'J') && ( +
+

+ Aujourd'hui{todayLabel ? ` — ${todayLabel}` : ''} vs Demain{tomorrowLabel ? ` — ${tomorrowLabel}` : ''} +

+
+ Phénomène + Aujourd'hui + {ech === 'J' + ? Demain + : Demain + } + {comparisonRows.map(({ id, phen, todayColor, tomorrowColor, changed }) => ( + <> + + {phen.label} + + + + + + {tomorrowColor !== null + ? ( + + + {changed && change} + + ) + : + } + + + ))} +
+
+ )} + + {/* Emergency numbers if there's an active alert */} + {topAlert && EMERGENCY_NUMBERS.length > 0 && ( +
+
Numéros d'urgence
+
{EMERGENCY_NUMBERS.map((n) => ( -
- +
+ {n.num} - {n.label} + {n.label}
))}
-
-
- ) - } + )} + + )} +
+ + {/* ── Température & climato ── */} + {climato && climato.days.length > 0 && ( +
+
+

Températures récentes

+ + {anomaly && ( +
+ +
+ )} +

+ 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). +

+
+
+ )} + + {/* ── Actions rapides ── */} + {!drom && !error && ( +
+ +
+ )} + + diff --git a/src/pages/soutenir.astro b/src/pages/soutenir.astro index 8dac072..994daf3 100644 --- a/src/pages/soutenir.astro +++ b/src/pages/soutenir.astro @@ -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 = [

- {/* Objectif de don */} -
-
-
-
Objectif de don en cours
-

{goalLabel}

-
-

- {goalRaised} {goalCurrency} - / {goalTarget} {goalCurrency} -

-
-
-
-
-

- {goalPercent}% atteint — une fois l'objectif rempli, le nom de domaine dédié sera réservé pour un an. -

+ {/* Paliers de don */} +
+
Objectifs de don
+ {goals.map((g, i) => { + const isActive = i === activeGoalIdx; + const isDone = goalRaised >= g.target; + const pct = isDone ? 100 : (i < activeGoalIdx ? 100 : goalPercent); + return ( +
+
+
+ {isDone && } + {isActive && } + {g.label} + {g.desc} +
+ + + {goalRaised} {goalCurrency} + + / {g.target} {goalCurrency} + +
+
+
+
+
+ ); + })}
{/* CTA principal */}