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

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
dist
.astro
.env
.env.local
.git
.github
.forgejo
*.md
!README.md

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# Application
NODE_ENV=development
PORT=4321
PUBLIC_SITE_URL=http://localhost:4321
# Valkey (Redis) — dev local utilise un container, prod utilise l'infra partagée
REDIS_URL=redis://localhost:6379/0
# Vigilance provider
# `opendatasoft` (default, no auth) ou `meteofrance` (token requis)
VIGILANCE_PROVIDER=opendatasoft
# TTL du cache Vigilance en secondes (Vigilance se met à jour 2x/jour, 15min raisonnable)
VIGILANCE_CACHE_TTL=900

9
.env.tmpl Normal file
View file

@ -0,0 +1,9 @@
NODE_ENV=production
PORT=4321
PUBLIC_SITE_URL=https://info-canicule.nocleus.com
# Valkey shared infra — DB 0 (isolation par préfixe `info-canicule:*` via ACL)
REDIS_URL=redis://info-canicule:{{ pass://Infra/Valkey — info-canicule/password }}@valkey:6379/0
VIGILANCE_PROVIDER=opendatasoft
VIGILANCE_CACHE_TTL=900

View file

@ -0,0 +1,34 @@
name: Deploy info-canicule
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
container:
image: alpine:3.20
steps:
- name: Install tooling
run: apk add --no-cache openssh-client git rsync
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy on VPS
run: |
ssh deploy@vps.nocleus.com bash -se <<'EOSSH'
set -euo pipefail
cd /opt/projects/info-canicule
git fetch --prune origin main
git reset --hard origin/main
make env
docker compose up -d --build --wait
docker image prune -f
EOSSH

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
dist
.astro
.env
.env.local
.DS_Store
*.log
.vscode/*.local
.idea

49
CLAUDE.md Normal file
View file

@ -0,0 +1,49 @@
# CLAUDE.md — info-canicule
## Objectif
Site d'utilité publique qui affiche en temps réel la Vigilance Météo France par département
+ les conseils officiels (canicule, orages, vent, pluie, neige, avalanches, vagues-submersion).
Hébergé sur le VPS Nocleus partagé (réseau `shared-net`, cache Valkey ACL `info-canicule`).
## Stack
- Astro 5 SSR (`output: 'server'`, adapter `@astrojs/node` mode standalone)
- TailwindCSS 3 (via `@astrojs/tailwind`)
- ioredis pour le cache Valkey
- TypeScript strict
- Pas de DB Postgres (le snapshot Vigilance vit en cache, refresh toutes les 15 min)
## Source de données
- **Provider par défaut** : `weatherref-france-vigilance-meteo-departement` chez Opendatasoft.
- URL : `https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records`
- Pas d'auth, JSON propre, ~1 000 records par snapshot (départements × phénomènes × échéances J/J1)
- Licence Ouverte 2.0
- **Migration possible** : API Vigilance Météo France officielle (portail-api.meteofrance.fr) si fraîcheur Opendatasoft insuffisante. Nécessite un token sur le portail Météo France. Abstraction `vigilance.ts` à découper en deux implémentations le jour venu.
## Schéma cache
| Clé Valkey | TTL | Contenu |
|---|---|---|
| `info-canicule:vigilance:snapshot` | `VIGILANCE_CACHE_TTL` (défaut 900s) | `VigilanceSnapshot` complet (fetchedAt + productDatetime + alerts[]) |
Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé réelle côté Valkey est `info-canicule:info-canicule:vigilance:snapshot`. Compatible avec l'ACL `~info-canicule:*` côté VPS.
## Pièges connus / à surveiller
- **`product_datetime` change 2× par jour** (~06h et 16h Paris). Tant que Météo France n'a pas publié le suivant, le snapshot Opendatasoft renvoie les valeurs J→J1 du bulletin courant. Inutile de poll plus vite que 15 min.
- **Echéance "J" = aujourd'hui, "J1" = demain**. Ne pas confondre avec un horizon en heures.
- **`phenomenon_id`** : 1=vent, 2=pluie, 3=orages, 5=neige/verglas, 6=canicule, 8=avalanches, 9=vagues-submersion. Pas de 4 ni 7.
- **Andorre** (`domain_id = 99`) est inclus dans le flux Vigilance mais n'est pas un département français. Mappé comme `99 — Andorre (zone Vigilance)` côté front pour ne pas l'écarter silencieusement.
- **Corse** : codes `2A` et `2B`, pas `20`.
- **DROM** : pas (encore) inclus dans le mapping `departements.ts` côté front. Vigilance Métropole only pour le MVP. Pour ajouter Outre-mer : étendre `DEPARTEMENTS` + tester si `domain_id` correspond aux codes 971-978.
- **Conseils par phénomène** (`advice.ts`) : texte curated depuis sante.gouv.fr et meteofrance.fr. À relire / actualiser périodiquement (au moins 1× par an).
- **Cache miss au boot** : si Valkey est down, `cacheOrFetch` log un warning mais re-fetch à chaque requête — pas de fallback persistant. Acceptable pour un service stateless, mais surveiller la latence Opendatasoft.
## Déploiement
Pattern Reteno : push main → CI Forgejo SSH au VPS → `git fetch && reset --hard && make env && docker compose up -d --build --wait`.
Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pass-cli, vault `Infra`).

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=4321 \
HOST=0.0.0.0
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/package.json ./
USER app
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4321/api/health || exit 1
CMD ["node", "./dist/server/entry.mjs"]

39
Makefile Normal file
View file

@ -0,0 +1,39 @@
.DEFAULT_GOAL := help
SHELL := /bin/bash
PASS_CLI ?= $(HOME)/.local/bin/pass-cli
.PHONY: help install dev build start env check up down logs restart
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Install deps
pnpm install
dev: ## Run Astro dev server (needs REDIS_URL — falls back to graceful warnings)
pnpm dev
build: ## Build SSR bundle
pnpm build
start: ## Run built bundle locally
node ./dist/server/entry.mjs
check: ## Type-check (astro check)
pnpm check
env: ## Materialize .env from .env.tmpl via pass-cli
$(PASS_CLI) inject --in-file .env.tmpl --out-file .env --file-mode 0600
up: ## docker compose up -d --build --wait (prod-like)
docker compose up -d --build --wait
down: ## docker compose down
docker compose down
logs: ## docker compose logs -f
docker compose logs -f --tail=100
restart: ## docker compose restart app
docker compose restart app

62
README.md Normal file
View file

@ -0,0 +1,62 @@
# info-canicule
Site d'utilité publique affichant les alertes Vigilance Météo France en temps réel
et les conseils officiels en cas d'événements météo dangereux (canicule, orages, vent, etc.).
- **Prod** : https://info-canicule.nocleus.com
- **Source données** : [meteo.data.gouv.fr](https://meteo.data.gouv.fr/) via [Opendatasoft](https://public.opendatasoft.com/) (Licence Ouverte 2.0)
- **Stack** : Astro 5 SSR + Node adapter, TailwindCSS, ioredis (cache Valkey)
## Dev local
```bash
make install
# Optionnel : Valkey local pour tester le cache
docker run -d --name valkey-dev -p 6379:6379 valkey/valkey:8-alpine
cp .env.example .env
make dev
```
Ouvrir http://localhost:4321.
## Prod (VPS)
```bash
# Premier déploiement (depuis le VPS)
git clone git@git.nocleus.com:florian/info-canicule.git /opt/projects/info-canicule
cd /opt/projects/info-canicule
make env # matérialise .env depuis vault Infra
make up # build + start, joint shared-net, atteint `valkey:6379`
```
Les déploiements suivants passent par la CI Forgejo (push main → workflow `deploy.yml`).
## Endpoints
- `/` — carte Vigilance par département (J)
- `/departement/<code>` — détail dept (alertes J + J1 + conseils contextuels)
- `/conseils` — conseils officiels pour les 7 phénomènes Vigilance
- `/api/health` — JSON `{status, cache, time}` pour UptimeRobot
- `/api/vigilance` — JSON public du snapshot Vigilance courant (CORS *)
## Structure
```
src/
├── layouts/Base.astro # shell HTML + header/footer
├── components/ # VigilanceChip, DepartementGrid, VigilanceLegend
├── lib/
│ ├── cache.ts # ioredis wrapper + cacheOrFetch helper
│ ├── vigilance.ts # fetch Opendatasoft + parse + maps utilitaires
│ ├── phenomena.ts # référentiel 7 phénomènes + couleurs
│ ├── departements.ts # liste INSEE des départements
│ └── advice.ts # conseils officiels par phénomène
└── pages/ # index, departement/[code], conseils, api/*
```
## Roadmap
- [ ] Vraie carte SVG géographique (GeoJSON départements + paths)
- [ ] Historique T° (données climato base quotidiennes — CSV.GZ par département)
- [ ] Détail "qui appeler / s'inscrire au registre canicule" par mairie/préfecture
- [ ] Switch optionnel vers l'API Météo France officielle (plus fraîche que Opendatasoft)

16
astro.config.mjs Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [tailwind({ applyBaseStyles: false })],
server: { host: '0.0.0.0', port: 4321 },
site: 'https://info-canicule.nocleus.com',
vite: {
ssr: {
noExternal: ['ioredis'],
},
},
});

File diff suppressed because one or more lines are too long

30
docker-compose.yml Normal file
View file

@ -0,0 +1,30 @@
# Compose de PROD côté VPS — déployé dans /opt/projects/info-canicule/.
# Le service rejoint le réseau shared-net pour atteindre `valkey:6379`.
# En dev local, utiliser `pnpm dev` directement (Valkey local optionnel).
services:
app:
build:
context: .
dockerfile: Dockerfile
image: info-canicule:latest
container_name: info-canicule-app
restart: unless-stopped
env_file: .env
environment:
- NODE_ENV=production
- PORT=4321
- HOST=0.0.0.0
networks:
- shared-net
mem_limit: 256m
cpus: 0.5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
shared-net:
external: true

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "info-canicule",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"astro": "astro",
"check": "astro check",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/node": "^9.2.2",
"@astrojs/tailwind": "^6.0.2",
"astro": "^5.7.0",
"ioredis": "^5.6.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@types/node": "^22.10.5",
"typescript": "^5.7.2"
},
"packageManager": "pnpm@10.0.0"
}

4842
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

5
public/favicon.svg Normal file
View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" fill="#ea580c"/>
<text x="32" y="44" font-size="34" text-anchor="middle" font-family="system-ui,sans-serif">🌡</text>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1,111 @@
#!/usr/bin/env node
// Génère un SVG paths data pour la carte des départements français.
// Source GeoJSON : github.com/gregoiredavid/france-geojson (Licence Ouverte v2.0).
// Projection : Mercator approchée. Suffisant pour visualisation, pas pour cartographie précise.
//
// Usage : node scripts/build-france-map.mjs
// Output : src/data/france-map.json (paths par code INSEE, viewBox)
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INPUT = resolve(__dirname, '../data-sources/departements-simplifie.geojson');
const OUTPUT = resolve(__dirname, '../src/data/france-map.json');
const W = 1000;
const H = 1000;
function project(lng, lat) {
// Mercator (lng/lat en degrés → coordonnées planes en radians)
const x = (lng * Math.PI) / 180;
const y = Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 180 / 2));
return [x, y];
}
function ringToPath(ring) {
return ring
.map(([lng, lat], i) => {
const [x, y] = project(lng, lat);
return `${i === 0 ? 'M' : 'L'}${x.toFixed(5)},${y.toFixed(5)}`;
})
.join('') + 'Z';
}
function geometryToPath(geom) {
if (geom.type === 'Polygon') {
return geom.coordinates.map(ringToPath).join('');
}
if (geom.type === 'MultiPolygon') {
return geom.coordinates.map((poly) => poly.map(ringToPath).join('')).join('');
}
return '';
}
const geojson = JSON.parse(readFileSync(INPUT, 'utf-8'));
// First pass: compute bounding box on projected coords.
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const f of geojson.features) {
const polys = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
for (const poly of polys) {
for (const ring of poly) {
for (const [lng, lat] of ring) {
const [x, y] = project(lng, lat);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
}
const scale = Math.min(W / (maxX - minX), H / (maxY - minY));
const offsetX = -minX * scale;
const offsetY = maxY * scale; // invert Y (SVG top-down)
function scaledRingToPath(ring) {
return ring
.map(([lng, lat], i) => {
const [x, y] = project(lng, lat);
const sx = (x * scale + offsetX).toFixed(2);
const sy = (offsetY - y * scale).toFixed(2);
return `${i === 0 ? 'M' : 'L'}${sx},${sy}`;
})
.join('') + 'Z';
}
function scaledGeometryToPath(geom) {
if (geom.type === 'Polygon') {
return geom.coordinates.map(scaledRingToPath).join('');
}
if (geom.type === 'MultiPolygon') {
return geom.coordinates.map((poly) => poly.map(scaledRingToPath).join('')).join('');
}
return '';
}
const paths = {};
for (const f of geojson.features) {
const code = String(f.properties.code);
paths[code] = {
d: scaledGeometryToPath(f.geometry),
name: f.properties.nom,
};
}
const finalW = (maxX - minX) * scale;
const finalH = (maxY - minY) * scale;
const output = {
viewBox: `0 0 ${finalW.toFixed(2)} ${finalH.toFixed(2)}`,
width: finalW.toFixed(0),
height: finalH.toFixed(0),
paths,
};
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, JSON.stringify(output));
console.log(`Wrote ${OUTPUT}${Object.keys(paths).length} départements, viewBox ${output.viewBox}`);

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;
}
}

28
tailwind.config.mjs Normal file
View file

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
vigilance: {
vert: '#5cb85c',
jaune: '#f6d800',
orange: '#f08c1a',
rouge: '#d9534f',
},
canicule: {
50: '#fff7ed',
100: '#ffedd5',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
900: '#7c2d12',
},
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
},
},
},
plugins: [],
};

11
tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}