feat(design): refonte page département + AnomalyBadge, paliers soutenir
All checks were successful
Deploy info-canicule / deploy (push) Successful in 1m31s
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:
parent
d5c0b0968d
commit
003b49c297
3 changed files with 436 additions and 260 deletions
|
|
@ -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 (1991–2020)
|
||||
</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 (1991–2020) : <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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue