init: info-canicule MVP (Vigilance + climato + conseils)

Astro 5 SSR + ioredis cache Valkey, déployable sur shared-net.
- Vigilance temps réel via Opendatasoft (no-auth, LOv2)
- Carte SVG des 96 départements (gregoiredavid/france-geojson)
- Climato T° 30j par dept (CSV.GZ Météo France, cache 24h)
- Conseils officiels par phénomène (7 types Vigilance)
- /api/health (UptimeRobot) + /api/vigilance (JSON public CORS *)
- Dockerfile multi-stage, CI Forgejo deploy.yml (pattern Reteno)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-25 18:17:56 +02:00
commit e075d963bc
37 changed files with 6730 additions and 0 deletions

View file

@ -0,0 +1,100 @@
---
import type { DayObservation } from '../lib/climato';
interface Props {
days: DayObservation[];
}
const { days } = Astro.props;
const validTx = days.filter((d) => d.tx !== null);
const validTn = days.filter((d) => d.tn !== null);
const allTemps = [...validTx.map((d) => d.tx!), ...validTn.map((d) => d.tn!)];
const minT = Math.floor(Math.min(...allTemps, 0));
const maxT = Math.ceil(Math.max(...allTemps, 30));
const W = 600;
const H = 200;
const PAD = 28;
const innerW = W - 2 * PAD;
const innerH = H - 2 * PAD;
const n = days.length || 1;
const span = maxT - minT || 1;
function x(i: number): number {
return PAD + (i / (n - 1 || 1)) * innerW;
}
function y(t: number): number {
return PAD + innerH - ((t - minT) / span) * innerH;
}
const txPath = validTx.length
? validTx
.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(days.indexOf(d)).toFixed(1)},${y(d.tx!).toFixed(1)}`)
.join('')
: '';
const tnPath = validTn.length
? validTn
.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(days.indexOf(d)).toFixed(1)},${y(d.tn!).toFixed(1)}`)
.join('')
: '';
const last = days[days.length - 1];
const heatGuide = 35; // T° canicule indicative
---
<figure class="rounded-lg border border-slate-200 bg-white p-4">
<figcaption class="mb-2 text-sm font-semibold text-slate-700">
Températures journalières des 30 derniers jours
{last && <span class="font-normal text-slate-500">— dernier point : {last.date}</span>}
</figcaption>
{days.length === 0 ? (
<p class="text-sm text-slate-500">Données climato non disponibles pour ce département.</p>
) : (
<svg viewBox={`0 0 ${W} ${H}`} class="h-auto w-full" role="img" aria-label="Graphique T° min/max">
{/* Heat guideline */}
{heatGuide >= minT && heatGuide <= maxT && (
<>
<line
x1={PAD}
x2={W - PAD}
y1={y(heatGuide)}
y2={y(heatGuide)}
stroke="#fb923c"
stroke-dasharray="3,3"
stroke-width="1"
/>
<text x={W - PAD} y={y(heatGuide) - 3} font-size="10" fill="#c2410c" text-anchor="end">
seuil canicule indicatif 35°C
</text>
</>
)}
{/* Axes */}
<line x1={PAD} y1={PAD} x2={PAD} y2={H - PAD} stroke="#cbd5e1" />
<line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="#cbd5e1" />
{/* Y labels */}
{[minT, Math.round((minT + maxT) / 2), maxT].map((t) => (
<text x={PAD - 4} y={y(t) + 3} font-size="10" fill="#64748b" text-anchor="end">
{t}°
</text>
))}
{/* Lines */}
{txPath && <path d={txPath} fill="none" stroke="#ea580c" stroke-width="2" />}
{tnPath && <path d={tnPath} fill="none" stroke="#3b82f6" stroke-width="2" />}
{/* Points (last 7 days highlighted) */}
{days.slice(-7).map((d) => {
const i = days.indexOf(d);
return (
<>
{d.tx !== null && <circle cx={x(i)} cy={y(d.tx)} r="2.5" fill="#ea580c" />}
{d.tn !== null && <circle cx={x(i)} cy={y(d.tn)} r="2.5" fill="#3b82f6" />}
</>
);
})}
</svg>
)}
<div class="mt-3 flex gap-4 text-xs">
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-canicule-600"></span> T° max (TX)</span>
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-blue-500"></span> T° min (TN)</span>
</div>
</figure>

View file

@ -0,0 +1,44 @@
---
// Grille de tous les départements, colorée selon le niveau Vigilance max.
// Placeholder visuel en attendant l'intégration d'une vraie carte SVG géographique.
import { DEPARTEMENTS, departementsByRegion } from '../lib/departements';
import { COLORS } from '../lib/phenomena';
import type { ColorId } from '../lib/phenomena';
interface Props {
colorsByDept: Map<string, ColorId>;
}
const { colorsByDept } = Astro.props;
const byRegion = departementsByRegion();
const regions = [...byRegion.keys()].sort();
---
<div class="space-y-6">
{
regions.map((region) => (
<section>
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wider text-slate-500">{region}</h3>
<div class="flex flex-wrap gap-2">
{byRegion.get(region)!.map((d) => {
const colorId = colorsByDept.get(d.code) ?? 1;
const color = COLORS[colorId];
return (
<a
href={`/departement/${d.code}`}
class="group relative flex h-12 w-16 items-center justify-center rounded border-2 text-xs font-bold no-underline transition hover:scale-105"
style={`background-color: ${color.hex}; border-color: ${color.hex};`}
title={`${d.name} (${d.code}) — niveau ${color.name}`}
>
<span class={colorId >= 3 ? 'text-white' : 'text-slate-900'}>{d.code}</span>
<span class="pointer-events-none absolute -bottom-7 left-1/2 z-10 hidden -translate-x-1/2 whitespace-nowrap rounded bg-slate-800 px-2 py-0.5 text-xs text-white group-hover:block">
{d.name}
</span>
</a>
);
})}
</div>
</section>
))
}
</div>

View file

@ -0,0 +1,40 @@
---
import franceMap from '../data/france-map.json';
import { COLORS } from '../lib/phenomena';
import type { ColorId } from '../lib/phenomena';
interface Props {
colorsByDept: Map<string, ColorId>;
}
const { colorsByDept } = Astro.props;
const entries = Object.entries(franceMap.paths) as [string, { d: string; name: string }][];
---
<svg
viewBox={franceMap.viewBox}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Carte des départements français colorée selon le niveau Vigilance"
class="h-auto w-full max-w-3xl"
>
<title>Carte Vigilance Météo France</title>
{
entries.map(([code, dept]) => {
const colorId = colorsByDept.get(code) ?? 1;
const color = COLORS[colorId];
return (
<a href={`/departement/${code}`} class="cursor-pointer">
<title>{`${dept.name} (${code}) — ${color.name}`}</title>
<path
d={dept.d}
fill={color.hex}
stroke="#ffffff"
stroke-width="0.8"
class="transition-opacity hover:opacity-80"
/>
</a>
);
})
}
</svg>

View file

@ -0,0 +1,20 @@
---
import { COLORS, COLOR_LABEL, PHENOMENA } from '../lib/phenomena';
import type { ColorId, PhenomenonId } from '../lib/phenomena';
interface Props {
colorId: ColorId;
phenomenonId?: PhenomenonId;
showLevel?: boolean;
}
const { colorId, phenomenonId, showLevel = false } = Astro.props;
const phenomenon = phenomenonId ? PHENOMENA[phenomenonId] : null;
const colorClass = `vigilance-chip-${colorId}`;
---
<span class:list={['vigilance-chip', colorClass]}>
{phenomenon && <span aria-hidden="true">{phenomenon.emoji}</span>}
{phenomenon ? phenomenon.label : COLORS[colorId].name}
{showLevel && <span class="text-xs opacity-75">— {COLOR_LABEL[colorId]}</span>}
</span>

View file

@ -0,0 +1,23 @@
---
import { COLORS, COLOR_LABEL } from '../lib/phenomena';
import type { ColorId } from '../lib/phenomena';
const levels: ColorId[] = [1, 2, 3, 4];
---
<div class="flex flex-wrap items-center gap-3 text-sm">
<span class="font-semibold text-slate-700">Niveaux :</span>
{
levels.map((id) => (
<span class="inline-flex items-center gap-2">
<span
class="inline-block h-4 w-4 rounded border border-slate-300"
style={`background-color: ${COLORS[id].hex};`}
aria-hidden="true"
/>
<span class="capitalize text-slate-700">{COLORS[id].name}</span>
<span class="hidden text-xs text-slate-500 sm:inline">— {COLOR_LABEL[id]}</span>
</span>
))
}
</div>

1
src/data/france-map.json Normal file

File diff suppressed because one or more lines are too long

67
src/layouts/Base.astro Normal file
View file

@ -0,0 +1,67 @@
---
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
}
const {
title = 'Info Canicule — Vigilance météo en temps réel',
description = 'Suivi en temps réel des alertes Vigilance Météo France et conseils officiels en cas de canicule, orages, tempêtes.',
} = Astro.props;
---
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="robots" content="index, follow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#ea580c" />
</head>
<body class="min-h-screen flex flex-col">
<header class="border-b border-slate-200 bg-white">
<div class="container-tight flex items-center justify-between py-4">
<a href="/" class="flex items-center gap-2 no-underline">
<span class="text-2xl">🌡️</span>
<span class="text-lg font-bold text-canicule-700">Info Canicule</span>
</a>
<nav class="flex items-center gap-6 text-sm font-medium text-slate-600">
<a href="/">Carte Vigilance</a>
<a href="/conseils">Conseils</a>
</nav>
</div>
</header>
<main class="flex-1">
<slot />
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="container-tight py-6 text-sm text-slate-500">
<p>
Données :
<a href="https://meteo.data.gouv.fr/" class="text-canicule-700" rel="noopener">Météo France</a>
via
<a href="https://public.opendatasoft.com/" class="text-canicule-700" rel="noopener">Opendatasoft</a>
— Licence Ouverte 2.0.
</p>
<p class="mt-1">
Service d'information publique sans valeur officielle. En cas de doute, suivre les consignes de
la Préfecture (
<a href="tel:112" class="text-canicule-700">112</a>
en urgence).
</p>
<p class="mt-2 text-xs text-slate-400">
Un projet
<a href="https://nocleus.com" class="text-canicule-700" rel="noopener">Nocleus</a>.
</p>
</div>
</footer>
</body>
</html>

191
src/lib/advice.ts Normal file
View file

@ -0,0 +1,191 @@
// Conseils officiels par phénomène — sources : meteofrance.fr/vigilance, sante.gouv.fr, gouvernement.fr.
// Le contenu reste informationnel : pour une décision urgente, suivre les consignes de la Préfecture.
import type { PhenomenonId } from './phenomena';
export interface AdviceBlock {
title: string;
items: string[];
}
export interface PhenomenonAdvice {
intro: string;
blocks: AdviceBlock[];
emergency: string[];
}
export const ADVICE: Record<PhenomenonId, PhenomenonAdvice> = {
6: {
intro:
"La canicule présente un risque sanitaire majeur, en particulier pour les nourrissons, les personnes âgées et les personnes malades. Les bons réflexes permettent de réduire significativement ce risque.",
blocks: [
{
title: 'Hydratation et alimentation',
items: [
"Boire régulièrement de l'eau sans attendre d'avoir soif (1,5 L par jour minimum)",
"Éviter l'alcool, les boissons sucrées et la caféine en excès",
'Prendre des repas légers et fractionnés, riches en fruits et légumes',
],
},
{
title: 'Rafraîchir son logement',
items: [
"Fermer volets et fenêtres aux heures les plus chaudes (10h-22h)",
"Aérer la nuit et tôt le matin quand l'air extérieur est plus frais",
"Mouiller son corps régulièrement (douche, brumisateur, linges humides)",
"Passer au moins 2-3 heures par jour dans un lieu climatisé (centre commercial, bibliothèque, mairie)",
],
},
{
title: 'Activité et habillement',
items: [
'Éviter les efforts physiques intenses entre 11h et 21h',
'Porter des vêtements amples, légers et clairs',
'Ne jamais laisser un enfant ou un animal dans une voiture, même quelques minutes',
],
},
{
title: 'Veiller sur les proches',
items: [
"Prendre des nouvelles des personnes isolées (voisins âgés, malades, sans-abri)",
"S'inscrire ou inscrire un proche fragile sur le registre canicule de sa mairie",
'En cas de symptôme inquiétant (maux de tête, vertiges, nausées, fièvre) : alerter rapidement',
],
},
],
emergency: [
'Coup de chaleur (T° corporelle > 40°C, confusion, peau rouge et sèche) : appeler le 15 immédiatement',
"En attendant les secours : transporter la personne dans un endroit frais, l'asperger d'eau, la ventiler",
],
},
1: {
intro: "Le vent violent peut provoquer des chutes d'objets, d'arbres et endommager les habitations.",
blocks: [
{
title: 'À la maison',
items: [
'Limiter les déplacements et reporter les activités de plein air',
"Ranger ou fixer les objets sensibles au vent (mobilier de jardin, parasols)",
'Ne pas se promener en forêt ni sur le littoral',
],
},
{
title: 'En voiture',
items: [
"Limiter la vitesse, en particulier les véhicules hauts (poids lourds, camping-cars)",
'Éviter les axes secondaires bordés d\'arbres',
],
},
],
emergency: ['En cas de chute d\'arbre ou de ligne électrique : appeler le 18 ou le 112'],
},
2: {
intro: "Les pluies intenses peuvent provoquer des inondations rapides et des crues.",
blocks: [
{
title: 'Avant',
items: [
'Surveiller la montée des eaux (rivières, ruisseaux, voiries)',
'Mettre en hauteur les objets de valeur et les produits dangereux',
],
},
{
title: 'Pendant',
items: [
"Ne pas s'engager sur une route inondée, même à pied (30 cm d'eau emportent une voiture)",
"S'éloigner des berges et ne pas descendre dans les sous-sols",
'Couper le gaz et l\'électricité si l\'eau approche',
],
},
],
emergency: ['Sapeurs-pompiers : 18 ou 112 (européen)'],
},
3: {
intro: "Les orages peuvent s'accompagner de foudre, vent violent, grêle et fortes pluies.",
blocks: [
{
title: 'À l\'extérieur',
items: [
"Se mettre à l'abri dans un bâtiment en dur (éviter les abris légers, tentes, voitures découvertes)",
'S\'éloigner des arbres isolés, pylônes, plans d\'eau',
'Ne pas se tenir debout dans un champ ou sur une crête',
],
},
{
title: 'À la maison',
items: [
'Débrancher les appareils électriques sensibles',
'Éviter d\'utiliser le téléphone fixe et de prendre une douche pendant l\'orage',
'Rentrer les animaux à l\'abri',
],
},
],
emergency: ['Personne foudroyée : appeler le 15, pratiquer un massage cardiaque si arrêt cardiaque'],
},
5: {
intro: "La neige et le verglas rendent la circulation dangereuse et augmentent les risques de chute.",
blocks: [
{
title: 'Déplacements',
items: [
'Privilégier les transports en commun et reporter les trajets non essentiels',
'Équiper le véhicule (pneus hiver, chaînes, couverture, eau, lampe)',
'Réduire la vitesse, augmenter les distances de sécurité',
],
},
{
title: 'À pied',
items: [
'Saler ou sabler les accès et trottoirs devant chez soi',
'Porter des chaussures à semelles antidérapantes',
],
},
],
emergency: ['SAMU : 15 — En montagne, secours en montagne : 112'],
},
8: {
intro: "Les avalanches menacent les zones de montagne enneigées. Les pratiquants du ski et de la randonnée sont les plus exposés.",
blocks: [
{
title: 'En montagne',
items: [
'Consulter le Bulletin d\'Estimation du Risque d\'Avalanche (BERA) avant toute sortie',
'Éviter les pentes raides (>30°) en période de risque élevé',
'S\'équiper : DVA (détecteur de victimes d\'avalanche), pelle, sonde',
'Ne pas s\'engager seul, prévenir de son itinéraire',
],
},
],
emergency: ['Secours en montagne : 112 (ou PGHM/CRS local)'],
},
9: {
intro: "Les vagues et la submersion marine présentent un risque élevé sur le littoral.",
blocks: [
{
title: 'Sur le littoral',
items: [
's\'éloigner du bord de mer, des digues et des jetées',
'Mettre à l\'abri les biens situés en zone basse',
'Surveiller les enfants en permanence, ne jamais les laisser seuls près de l\'eau',
],
},
{
title: 'En mer',
items: [
'Annuler toute sortie en mer (plaisance, pêche, plongée)',
'Rentrer les bateaux au port et les amarrer solidement',
],
},
],
emergency: ['CROSS : 196 (sauvetage en mer)'],
},
};
export const EMERGENCY_NUMBERS = [
{ num: '15', label: 'SAMU (urgences médicales)' },
{ num: '17', label: 'Police / Gendarmerie' },
{ num: '18', label: 'Sapeurs-pompiers' },
{ num: '112', label: "Numéro d'urgence européen" },
{ num: '114', label: 'Urgences pour personnes sourdes/malentendantes (SMS)' },
{ num: '196', label: 'Secours en mer (CROSS)' },
];

60
src/lib/cache.ts Normal file
View file

@ -0,0 +1,60 @@
import Redis from 'ioredis';
let client: Redis | null = null;
function getClient(): Redis {
if (client) return client;
const url = process.env.REDIS_URL;
if (!url) {
throw new Error('REDIS_URL is required');
}
client = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: 2,
keyPrefix: 'info-canicule:',
});
client.on('error', (err) => {
console.error('[redis] error:', err.message);
});
return client;
}
export async function cacheGet<T>(key: string): Promise<T | null> {
try {
const raw = await getClient().get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch (err) {
console.warn('[cache] get failed for', key, (err as Error).message);
return null;
}
}
export async function cacheSet<T>(key: string, value: T, ttlSec: number): Promise<void> {
try {
await getClient().set(key, JSON.stringify(value), 'EX', ttlSec);
} catch (err) {
console.warn('[cache] set failed for', key, (err as Error).message);
}
}
export async function cacheOrFetch<T>(
key: string,
ttlSec: number,
fetcher: () => Promise<T>,
): Promise<T> {
const cached = await cacheGet<T>(key);
if (cached !== null) return cached;
const fresh = await fetcher();
await cacheSet(key, fresh, ttlSec);
return fresh;
}
export async function pingCache(): Promise<boolean> {
try {
const res = await getClient().ping();
return res === 'PONG';
} catch {
return false;
}
}

115
src/lib/climato.ts Normal file
View file

@ -0,0 +1,115 @@
import { gunzipSync } from 'node:zlib';
import { cacheOrFetch } from './cache';
const BASE = 'https://object.files.data.gouv.fr/meteofrance/data/synchro_ftp/BASE/QUOT';
// Format des fichiers : Q_<DEPT>_latest-2025-2026_RR-T-Vent.csv.gz
// Colonnes utiles : NUM_POSTE, AAAAMMJJ, RR (mm), TN (°C), TX (°C), TM (°C).
// Délimiteur : ';'. Valeurs manquantes : vide.
// Une seule période "latest" couvre l'année courante.
export interface DayObservation {
date: string; // YYYY-MM-DD
tn: number | null;
tx: number | null;
tm: number | null;
rr: number | null;
stations: number;
}
export interface ClimatoSeries {
dept: string;
fetchedAt: string;
days: DayObservation[]; // 30 derniers jours, sorted asc
}
const PERIOD = 'latest-2025-2026';
// Mapping département front → fichier (Andorre 99 et DROM pas dans la base classique).
function buildUrl(dept: string): string | null {
if (dept === '99') return null;
if (dept === '2A' || dept === '2B') {
// Corse a son propre fichier 2A/2B selon le dataset.
return `${BASE}/Q_${dept}_${PERIOD}_RR-T-Vent.csv.gz`;
}
return `${BASE}/Q_${dept}_${PERIOD}_RR-T-Vent.csv.gz`;
}
function parseCsv(text: string): ClimatoSeries['days'] {
const lines = text.split(/\r?\n/);
if (lines.length < 2) return [];
const header = lines[0].split(';');
const idx = {
date: header.indexOf('AAAAMMJJ'),
tn: header.indexOf('TN'),
tx: header.indexOf('TX'),
tm: header.indexOf('TM'),
rr: header.indexOf('RR'),
};
if (idx.date === -1) return [];
// Aggregate by date across stations (mean of available values).
type Agg = { tnSum: number; tnN: number; txSum: number; txN: number; tmSum: number; tmN: number; rrSum: number; rrN: number; stations: number };
const byDate = new Map<string, Agg>();
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const cols = line.split(';');
const raw = cols[idx.date];
if (!raw || raw.length !== 8) continue;
const date = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
let agg = byDate.get(date);
if (!agg) {
agg = { tnSum: 0, tnN: 0, txSum: 0, txN: 0, tmSum: 0, tmN: 0, rrSum: 0, rrN: 0, stations: 0 };
byDate.set(date, agg);
}
agg.stations++;
const addNum = (raw: string | undefined, sum: 'tnSum' | 'txSum' | 'tmSum' | 'rrSum', n: 'tnN' | 'txN' | 'tmN' | 'rrN') => {
if (!raw) return;
const v = parseFloat(raw.replace(',', '.'));
if (Number.isFinite(v)) {
agg![sum] += v;
agg![n]++;
}
};
if (idx.tn !== -1) addNum(cols[idx.tn], 'tnSum', 'tnN');
if (idx.tx !== -1) addNum(cols[idx.tx], 'txSum', 'txN');
if (idx.tm !== -1) addNum(cols[idx.tm], 'tmSum', 'tmN');
if (idx.rr !== -1) addNum(cols[idx.rr], 'rrSum', 'rrN');
}
const days: DayObservation[] = [...byDate.entries()]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([date, agg]) => ({
date,
tn: agg.tnN > 0 ? +(agg.tnSum / agg.tnN).toFixed(1) : null,
tx: agg.txN > 0 ? +(agg.txSum / agg.txN).toFixed(1) : null,
tm: agg.tmN > 0 ? +(agg.tmSum / agg.tmN).toFixed(1) : null,
rr: agg.rrN > 0 ? +(agg.rrSum / agg.rrN).toFixed(1) : null,
stations: agg.stations,
}));
// Garder les 30 derniers jours.
return days.slice(-30);
}
async function fetchClimato(dept: string): Promise<ClimatoSeries> {
const url = buildUrl(dept);
if (!url) {
return { dept, fetchedAt: new Date().toISOString(), days: [] };
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`climato fetch ${dept} failed: ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
const text = gunzipSync(buf).toString('utf-8');
const days = parseCsv(text);
return { dept, fetchedAt: new Date().toISOString(), days };
}
export async function getClimatoForDepartement(dept: string): Promise<ClimatoSeries> {
// Cache 24h — les données journalières arrivent en J+1.
const ttl = 24 * 60 * 60;
return cacheOrFetch(`climato:${dept}`, ttl, () => fetchClimato(dept));
}

125
src/lib/departements.ts Normal file
View file

@ -0,0 +1,125 @@
// Mapping INSEE département codes → nom + chef-lieu.
// Source : code officiel géographique INSEE 2024 (métropole + DROM).
// Vigilance Météo France couvre métropole (01-95), Corse (2A/2B), DROM (971-978).
export interface Departement {
code: string;
name: string;
region: string;
}
export const DEPARTEMENTS: Departement[] = [
{ code: '01', name: 'Ain', region: 'Auvergne-Rhône-Alpes' },
{ code: '02', name: 'Aisne', region: 'Hauts-de-France' },
{ code: '03', name: 'Allier', region: 'Auvergne-Rhône-Alpes' },
{ code: '04', name: 'Alpes-de-Haute-Provence', region: "Provence-Alpes-Côte d'Azur" },
{ code: '05', name: 'Hautes-Alpes', region: "Provence-Alpes-Côte d'Azur" },
{ code: '06', name: 'Alpes-Maritimes', region: "Provence-Alpes-Côte d'Azur" },
{ code: '07', name: 'Ardèche', region: 'Auvergne-Rhône-Alpes' },
{ code: '08', name: 'Ardennes', region: 'Grand Est' },
{ code: '09', name: 'Ariège', region: 'Occitanie' },
{ code: '10', name: 'Aube', region: 'Grand Est' },
{ code: '11', name: 'Aude', region: 'Occitanie' },
{ code: '12', name: 'Aveyron', region: 'Occitanie' },
{ code: '13', name: 'Bouches-du-Rhône', region: "Provence-Alpes-Côte d'Azur" },
{ code: '14', name: 'Calvados', region: 'Normandie' },
{ code: '15', name: 'Cantal', region: 'Auvergne-Rhône-Alpes' },
{ code: '16', name: 'Charente', region: 'Nouvelle-Aquitaine' },
{ code: '17', name: 'Charente-Maritime', region: 'Nouvelle-Aquitaine' },
{ code: '18', name: 'Cher', region: 'Centre-Val de Loire' },
{ code: '19', name: 'Corrèze', region: 'Nouvelle-Aquitaine' },
{ code: '2A', name: 'Corse-du-Sud', region: 'Corse' },
{ code: '2B', name: 'Haute-Corse', region: 'Corse' },
{ code: '21', name: "Côte-d'Or", region: 'Bourgogne-Franche-Comté' },
{ code: '22', name: "Côtes-d'Armor", region: 'Bretagne' },
{ code: '23', name: 'Creuse', region: 'Nouvelle-Aquitaine' },
{ code: '24', name: 'Dordogne', region: 'Nouvelle-Aquitaine' },
{ code: '25', name: 'Doubs', region: 'Bourgogne-Franche-Comté' },
{ code: '26', name: 'Drôme', region: 'Auvergne-Rhône-Alpes' },
{ code: '27', name: 'Eure', region: 'Normandie' },
{ code: '28', name: 'Eure-et-Loir', region: 'Centre-Val de Loire' },
{ code: '29', name: 'Finistère', region: 'Bretagne' },
{ code: '30', name: 'Gard', region: 'Occitanie' },
{ code: '31', name: 'Haute-Garonne', region: 'Occitanie' },
{ code: '32', name: 'Gers', region: 'Occitanie' },
{ code: '33', name: 'Gironde', region: 'Nouvelle-Aquitaine' },
{ code: '34', name: 'Hérault', region: 'Occitanie' },
{ code: '35', name: 'Ille-et-Vilaine', region: 'Bretagne' },
{ code: '36', name: 'Indre', region: 'Centre-Val de Loire' },
{ code: '37', name: 'Indre-et-Loire', region: 'Centre-Val de Loire' },
{ code: '38', name: 'Isère', region: 'Auvergne-Rhône-Alpes' },
{ code: '39', name: 'Jura', region: 'Bourgogne-Franche-Comté' },
{ code: '40', name: 'Landes', region: 'Nouvelle-Aquitaine' },
{ code: '41', name: 'Loir-et-Cher', region: 'Centre-Val de Loire' },
{ code: '42', name: 'Loire', region: 'Auvergne-Rhône-Alpes' },
{ code: '43', name: 'Haute-Loire', region: 'Auvergne-Rhône-Alpes' },
{ code: '44', name: 'Loire-Atlantique', region: 'Pays de la Loire' },
{ code: '45', name: 'Loiret', region: 'Centre-Val de Loire' },
{ code: '46', name: 'Lot', region: 'Occitanie' },
{ code: '47', name: 'Lot-et-Garonne', region: 'Nouvelle-Aquitaine' },
{ code: '48', name: 'Lozère', region: 'Occitanie' },
{ code: '49', name: 'Maine-et-Loire', region: 'Pays de la Loire' },
{ code: '50', name: 'Manche', region: 'Normandie' },
{ code: '51', name: 'Marne', region: 'Grand Est' },
{ code: '52', name: 'Haute-Marne', region: 'Grand Est' },
{ code: '53', name: 'Mayenne', region: 'Pays de la Loire' },
{ code: '54', name: 'Meurthe-et-Moselle', region: 'Grand Est' },
{ code: '55', name: 'Meuse', region: 'Grand Est' },
{ code: '56', name: 'Morbihan', region: 'Bretagne' },
{ code: '57', name: 'Moselle', region: 'Grand Est' },
{ code: '58', name: 'Nièvre', region: 'Bourgogne-Franche-Comté' },
{ code: '59', name: 'Nord', region: 'Hauts-de-France' },
{ code: '60', name: 'Oise', region: 'Hauts-de-France' },
{ code: '61', name: 'Orne', region: 'Normandie' },
{ code: '62', name: 'Pas-de-Calais', region: 'Hauts-de-France' },
{ code: '63', name: 'Puy-de-Dôme', region: 'Auvergne-Rhône-Alpes' },
{ code: '64', name: 'Pyrénées-Atlantiques', region: 'Nouvelle-Aquitaine' },
{ code: '65', name: 'Hautes-Pyrénées', region: 'Occitanie' },
{ code: '66', name: 'Pyrénées-Orientales', region: 'Occitanie' },
{ code: '67', name: 'Bas-Rhin', region: 'Grand Est' },
{ code: '68', name: 'Haut-Rhin', region: 'Grand Est' },
{ code: '69', name: 'Rhône', region: 'Auvergne-Rhône-Alpes' },
{ code: '70', name: 'Haute-Saône', region: 'Bourgogne-Franche-Comté' },
{ code: '71', name: 'Saône-et-Loire', region: 'Bourgogne-Franche-Comté' },
{ code: '72', name: 'Sarthe', region: 'Pays de la Loire' },
{ code: '73', name: 'Savoie', region: 'Auvergne-Rhône-Alpes' },
{ code: '74', name: 'Haute-Savoie', region: 'Auvergne-Rhône-Alpes' },
{ code: '75', name: 'Paris', region: 'Île-de-France' },
{ code: '76', name: 'Seine-Maritime', region: 'Normandie' },
{ code: '77', name: 'Seine-et-Marne', region: 'Île-de-France' },
{ code: '78', name: 'Yvelines', region: 'Île-de-France' },
{ code: '79', name: 'Deux-Sèvres', region: 'Nouvelle-Aquitaine' },
{ code: '80', name: 'Somme', region: 'Hauts-de-France' },
{ code: '81', name: 'Tarn', region: 'Occitanie' },
{ code: '82', name: 'Tarn-et-Garonne', region: 'Occitanie' },
{ code: '83', name: 'Var', region: "Provence-Alpes-Côte d'Azur" },
{ code: '84', name: 'Vaucluse', region: "Provence-Alpes-Côte d'Azur" },
{ code: '85', name: 'Vendée', region: 'Pays de la Loire' },
{ code: '86', name: 'Vienne', region: 'Nouvelle-Aquitaine' },
{ code: '87', name: 'Haute-Vienne', region: 'Nouvelle-Aquitaine' },
{ code: '88', name: 'Vosges', region: 'Grand Est' },
{ code: '89', name: 'Yonne', region: 'Bourgogne-Franche-Comté' },
{ code: '90', name: 'Territoire de Belfort', region: 'Bourgogne-Franche-Comté' },
{ code: '91', name: 'Essonne', region: 'Île-de-France' },
{ code: '92', name: 'Hauts-de-Seine', region: 'Île-de-France' },
{ code: '93', name: 'Seine-Saint-Denis', region: 'Île-de-France' },
{ code: '94', name: 'Val-de-Marne', region: 'Île-de-France' },
{ code: '95', name: "Val-d'Oise", region: 'Île-de-France' },
{ code: '99', name: 'Andorre (zone Vigilance)', region: 'Hors France' },
];
const BY_CODE = new Map(DEPARTEMENTS.map((d) => [d.code, d]));
export function getDepartement(code: string): Departement | undefined {
return BY_CODE.get(code);
}
export function departementsByRegion(): Map<string, Departement[]> {
const map = new Map<string, Departement[]>();
for (const d of DEPARTEMENTS) {
const list = map.get(d.region) ?? [];
list.push(d);
map.set(d.region, list);
}
return map;
}

40
src/lib/phenomena.ts Normal file
View file

@ -0,0 +1,40 @@
// Référentiel des 7 phénomènes Vigilance Météo France.
export type PhenomenonId = 1 | 2 | 3 | 5 | 6 | 8 | 9;
export type ColorId = 1 | 2 | 3 | 4;
export type ColorName = 'vert' | 'jaune' | 'orange' | 'rouge';
export interface Phenomenon {
id: PhenomenonId;
slug: string;
label: string;
emoji: string;
}
export const PHENOMENA: Record<PhenomenonId, Phenomenon> = {
1: { id: 1, slug: 'vent', label: 'Vent violent', emoji: '💨' },
2: { id: 2, slug: 'pluie', label: 'Pluie-inondation', emoji: '🌧️' },
3: { id: 3, slug: 'orages', label: 'Orages', emoji: '⛈️' },
5: { id: 5, slug: 'neige', label: 'Neige / verglas', emoji: '❄️' },
6: { id: 6, slug: 'canicule', label: 'Canicule', emoji: '🌡️' },
8: { id: 8, slug: 'avalanches', label: 'Avalanches', emoji: '🏔️' },
9: { id: 9, slug: 'vagues-submersion', label: 'Vagues-submersion', emoji: '🌊' },
};
export const COLORS: Record<ColorId, { name: ColorName; hex: string; level: number }> = {
1: { name: 'vert', hex: '#5cb85c', level: 1 },
2: { name: 'jaune', hex: '#f6d800', level: 2 },
3: { name: 'orange', hex: '#f08c1a', level: 3 },
4: { name: 'rouge', hex: '#d9534f', level: 4 },
};
export const COLOR_LABEL: Record<ColorId, string> = {
1: 'Pas de vigilance particulière',
2: 'Soyez attentif',
3: 'Soyez très vigilant',
4: 'Vigilance absolue',
};
export function phenomenonBySlug(slug: string): Phenomenon | undefined {
return Object.values(PHENOMENA).find((p) => p.slug === slug);
}

106
src/lib/vigilance.ts Normal file
View file

@ -0,0 +1,106 @@
import { cacheOrFetch } from './cache';
import type { ColorId, PhenomenonId } from './phenomena';
import { COLORS, PHENOMENA } from './phenomena';
export interface VigilanceAlert {
departement: string;
echeance: 'J' | 'J1';
phenomenonId: PhenomenonId;
colorId: ColorId;
beginTime: string;
endTime: string;
productDatetime: string;
}
export interface VigilanceSnapshot {
fetchedAt: string;
productDatetime: string | null;
alerts: VigilanceAlert[];
}
const OPENDATASOFT_BASE =
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records';
const CACHE_KEY = 'vigilance:snapshot';
async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
const url = `${OPENDATASOFT_BASE}?limit=100&offset=0`;
const all: VigilanceAlert[] = [];
let offset = 0;
let total = Infinity;
while (offset < total && offset < 5000) {
const res = await fetch(`${OPENDATASOFT_BASE}?limit=100&offset=${offset}`);
if (!res.ok) {
throw new Error(`Opendatasoft fetch failed: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as {
total_count: number;
results: Array<{
domain_id: string;
echeance: 'J' | 'J1';
phenomenon_id: number;
color_id: number;
begin_time: string;
end_time: string;
product_datetime: string;
}>;
};
total = json.total_count;
for (const r of json.results) {
if (!(r.phenomenon_id in PHENOMENA)) continue;
if (!(r.color_id in COLORS)) continue;
all.push({
departement: r.domain_id,
echeance: r.echeance,
phenomenonId: r.phenomenon_id as PhenomenonId,
colorId: r.color_id as ColorId,
beginTime: r.begin_time,
endTime: r.end_time,
productDatetime: r.product_datetime,
});
}
offset += json.results.length;
if (json.results.length === 0) break;
}
const latestProduct = all.reduce<string | null>(
(acc, a) => (acc === null || a.productDatetime > acc ? a.productDatetime : acc),
null,
);
return {
fetchedAt: new Date().toISOString(),
productDatetime: latestProduct,
alerts: all,
};
}
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
return cacheOrFetch(CACHE_KEY, ttl, fetchOpendatasoft);
}
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
const map = new Map<string, ColorId>();
for (const a of snapshot.alerts) {
if (a.echeance !== echeance) continue;
const current = map.get(a.departement) ?? 1;
if (a.colorId > current) map.set(a.departement, a.colorId);
}
return map;
}
export function alertsForDepartement(
snapshot: VigilanceSnapshot,
code: string,
echeance: 'J' | 'J1' = 'J',
): VigilanceAlert[] {
return snapshot.alerts
.filter((a) => a.departement === code && a.echeance === echeance)
.sort((a, b) => b.colorId - a.colorId);
}
export function activeAlerts(snapshot: VigilanceSnapshot, minColor: ColorId = 2): VigilanceAlert[] {
return snapshot.alerts.filter((a) => a.colorId >= minColor && a.echeance === 'J');
}

17
src/pages/api/health.ts Normal file
View file

@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { pingCache } from '../../lib/cache';
export const prerender = false;
export const GET: APIRoute = async () => {
const cacheOk = await pingCache();
const body = {
status: cacheOk ? 'ok' : 'degraded',
cache: cacheOk,
time: new Date().toISOString(),
};
return new Response(JSON.stringify(body), {
status: cacheOk ? 200 : 503,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
};

View file

@ -0,0 +1,24 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot } from '../../lib/vigilance';
export const prerender = false;
// JSON public du snapshot Vigilance actuel — réutilisable sous Licence Ouverte 2.0.
export const GET: APIRoute = async () => {
try {
const snap = await getVigilanceSnapshot();
return new Response(JSON.stringify(snap), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -0,0 +1,82 @@
---
import Base from '../../layouts/Base.astro';
import { PHENOMENA } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
export const prerender = false;
const phenomenaList = Object.values(PHENOMENA).sort((a, b) =>
a.id === 6 ? -1 : b.id === 6 ? 1 : a.label.localeCompare(b.label),
);
---
<Base
title="Conseils officiels — Vigilance météo"
description="Conseils officiels du gouvernement et de Météo France en cas d'alerte météorologique (canicule, orages, vent, inondations)."
>
<section class="bg-gradient-to-b from-canicule-50 to-white">
<div class="container-tight py-8">
<h1 class="text-3xl font-bold sm:text-4xl">Conseils officiels</h1>
<p class="mt-2 max-w-2xl text-slate-600">
Recommandations à appliquer en cas d'alerte Vigilance, par type de phénomène.
Sources : Météo France, santé.gouv.fr, gouvernement.fr.
</p>
</div>
</section>
<section class="container-tight py-8">
<div class="space-y-12">
{
phenomenaList.map((p) => {
const advice = ADVICE[p.id];
return (
<article id={p.slug} class="scroll-mt-20">
<h2 class="text-2xl font-bold">
<span aria-hidden="true">{p.emoji}</span> {p.label}
</h2>
<p class="mt-2 text-slate-700">{advice.intro}</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
{advice.blocks.map((b) => (
<div class="rounded-lg border border-slate-200 bg-white p-4">
<h3 class="font-semibold">{b.title}</h3>
<ul class="mt-2 list-inside list-disc space-y-1 text-sm text-slate-700">
{b.items.map((i) => <li>{i}</li>)}
</ul>
</div>
))}
</div>
{advice.emergency.length > 0 && (
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4">
<h3 class="font-semibold text-red-900">Urgence</h3>
<ul class="mt-1 space-y-1 text-sm text-red-800">
{advice.emergency.map((e) => <li>{e}</li>)}
</ul>
</div>
)}
</article>
);
})
}
</div>
</section>
<section class="border-t border-slate-200 bg-white">
<div class="container-tight py-8">
<h2 class="text-xl font-semibold">Numéros d'urgence</h2>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{
EMERGENCY_NUMBERS.map((n) => (
<div class="flex items-baseline gap-2 rounded border border-slate-200 px-3 py-2">
<a href={`tel:${n.num}`} class="font-mono text-lg font-bold text-canicule-700">
{n.num}
</a>
<span class="text-sm text-slate-700">{n.label}</span>
</div>
))
}
</div>
</div>
</section>
</Base>

View file

@ -0,0 +1,182 @@
---
import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro';
import VigilanceLegend from '../../components/VigilanceLegend.astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../lib/vigilance';
import { getDepartement } from '../../lib/departements';
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
import { getClimatoForDepartement } from '../../lib/climato';
import ClimatoChart from '../../components/ClimatoChart.astro';
export const prerender = false;
const { code } = Astro.params;
const dept = code ? getDepartement(code.toUpperCase()) : undefined;
if (!dept) {
return new Response('Département introuvable', { status: 404 });
}
let snapshot;
let error: string | null = null;
try {
snapshot = await getVigilanceSnapshot();
} catch (e) {
error = (e as Error).message;
}
let climato = null;
try {
climato = await getClimatoForDepartement(dept.code);
} catch (e) {
console.warn('climato fetch failed for', dept.code, (e as Error).message);
}
const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
const tomorrow = snapshot ? alertsForDepartement(snapshot, dept.code, 'J1') : [];
const highest = today[0];
const adviceFor = highest && ADVICE[highest.phenomenonId];
---
<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>
</div>
</section>
<section class="container-tight py-8">
{error && <p class="text-red-700">Données indisponibles : {error}</p>}
{
!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 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>
)
}
{
!error && today.length > 0 && (
<>
<h2 class="mb-3 text-xl font-semibold">Alertes en cours</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>
</>
)
}
{
!error && tomorrow.length > 0 && (
<div class="mt-8">
<h2 class="mb-3 text-xl font-semibold">Prévision 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>
<ClimatoChart days={climato.days} />
<p class="mt-2 text-xs text-slate-500">
Source : Météo France — données climatologiques de base quotidiennes, agrégées par moyenne
sur toutes les stations du département. Donnée brute, contrôle qualité Météo France.
</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}
</a>
<span class="text-slate-600">{n.label}</span>
</div>
))}
</div>
</div>
</div>
</section>
)
}
</Base>

128
src/pages/index.astro Normal file
View file

@ -0,0 +1,128 @@
---
import Base from '../layouts/Base.astro';
import FranceMap from '../components/FranceMap.astro';
import DepartementGrid from '../components/DepartementGrid.astro';
import VigilanceLegend from '../components/VigilanceLegend.astro';
import VigilanceChip from '../components/VigilanceChip.astro';
import { getVigilanceSnapshot, maxColorByDepartement, activeAlerts } from '../lib/vigilance';
import { getDepartement } from '../lib/departements';
import { PHENOMENA, COLORS } from '../lib/phenomena';
export const prerender = false;
let snapshot;
let error: string | null = null;
try {
snapshot = await getVigilanceSnapshot();
} catch (e) {
error = (e as Error).message;
}
const colorsByDept = snapshot ? maxColorByDepartement(snapshot, 'J') : new Map();
const alertsToday = snapshot ? activeAlerts(snapshot, 2) : [];
const canicule = alertsToday.filter((a) => a.phenomenonId === 6).length;
const orages = alertsToday.filter((a) => a.phenomenonId === 3).length;
const orange = alertsToday.filter((a) => a.colorId >= 3).length;
const productDate = snapshot?.productDatetime
? new Date(snapshot.productDatetime).toLocaleString('fr-FR', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'Europe/Paris',
})
: 'inconnu';
---
<Base>
<section class="bg-gradient-to-b from-canicule-50 to-white">
<div class="container-tight py-10">
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl">
Vigilance Météo France en temps réel
</h1>
<p class="mt-2 max-w-2xl text-slate-600">
Carte des alertes Vigilance par département, conseils officiels et numéros d'urgence.
Bulletin Météo France du <strong>{productDate}</strong> (heure de Paris).
</p>
</div>
</section>
<section class="container-tight py-8">
{
error && (
<div class="rounded border border-red-200 bg-red-50 p-4 text-red-800">
<strong>Données indisponibles.</strong> Réessayer dans quelques minutes. Détail technique :
{error}
</div>
)
}
{
!error && (
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-canicule-700">{canicule}</div>
<div class="text-sm text-slate-600">départements en vigilance canicule</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-orange-700">{orange}</div>
<div class="text-sm text-slate-600">en niveau orange ou rouge (tous phénomènes)</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-2xl font-bold text-yellow-700">{orages}</div>
<div class="text-sm text-slate-600">en vigilance orages</div>
</div>
</div>
)
}
</section>
{
!error && (
<section class="container-tight pb-8">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold text-slate-900">Niveau par département (aujourd'hui)</h2>
<VigilanceLegend />
</div>
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div class="flex justify-center">
<FranceMap colorsByDept={colorsByDept} />
</div>
<details class="rounded border border-slate-200 bg-white p-4">
<summary class="cursor-pointer font-medium text-slate-700">
Vue par région (liste)
</summary>
<div class="mt-4">
<DepartementGrid colorsByDept={colorsByDept} />
</div>
</details>
</div>
</section>
)
}
{
!error && alertsToday.length > 0 && (
<section class="border-t border-slate-200 bg-white">
<div class="container-tight py-8">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Alertes actives</h2>
<ul class="space-y-2">
{alertsToday
.sort((a, b) => b.colorId - a.colorId)
.slice(0, 50)
.map((a) => {
const dept = getDepartement(a.departement);
if (!dept) return null;
return (
<li class="flex flex-wrap items-center justify-between gap-3 rounded border border-slate-200 px-3 py-2">
<a href={`/departement/${a.departement}`} class="font-medium no-underline">
{dept.name} ({a.departement})
</a>
<VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} />
</li>
);
})}
</ul>
</div>
</section>
)
}
</Base>

36
src/styles/global.css Normal file
View file

@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply text-slate-900 antialiased;
}
body {
@apply bg-slate-50;
}
a {
@apply underline-offset-2 hover:underline;
}
}
@layer components {
.container-tight {
@apply mx-auto w-full max-w-5xl px-4 sm:px-6;
}
.vigilance-chip {
@apply inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ring-1 ring-inset;
}
.vigilance-chip-1 {
@apply bg-green-50 text-green-800 ring-green-200;
}
.vigilance-chip-2 {
@apply bg-yellow-50 text-yellow-900 ring-yellow-300;
}
.vigilance-chip-3 {
@apply bg-orange-50 text-orange-900 ring-orange-300;
}
.vigilance-chip-4 {
@apply bg-red-50 text-red-900 ring-red-300;
}
}