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:
commit
e075d963bc
37 changed files with 6730 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.forgejo
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
14
.env.example
Normal file
14
.env.example
Normal 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
9
.env.tmpl
Normal 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
|
||||||
34
.forgejo/workflows/deploy.yml
Normal file
34
.forgejo/workflows/deploy.yml
Normal 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
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vscode/*.local
|
||||||
|
.idea
|
||||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal 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
31
Dockerfile
Normal 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
39
Makefile
Normal 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
62
README.md
Normal 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
16
astro.config.mjs
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1
data-sources/departements-simplifie.geojson
Normal file
1
data-sources/departements-simplifie.geojson
Normal file
File diff suppressed because one or more lines are too long
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal 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
28
package.json
Normal 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
4842
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal 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 |
111
scripts/build-france-map.mjs
Normal file
111
scripts/build-france-map.mjs
Normal 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}`);
|
||||||
100
src/components/ClimatoChart.astro
Normal file
100
src/components/ClimatoChart.astro
Normal 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>
|
||||||
44
src/components/DepartementGrid.astro
Normal file
44
src/components/DepartementGrid.astro
Normal 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>
|
||||||
40
src/components/FranceMap.astro
Normal file
40
src/components/FranceMap.astro
Normal 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>
|
||||||
20
src/components/VigilanceChip.astro
Normal file
20
src/components/VigilanceChip.astro
Normal 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>
|
||||||
23
src/components/VigilanceLegend.astro
Normal file
23
src/components/VigilanceLegend.astro
Normal 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
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
67
src/layouts/Base.astro
Normal 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
191
src/lib/advice.ts
Normal 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
60
src/lib/cache.ts
Normal 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
115
src/lib/climato.ts
Normal 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
125
src/lib/departements.ts
Normal 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
40
src/lib/phenomena.ts
Normal 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
106
src/lib/vigilance.ts
Normal 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
17
src/pages/api/health.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
24
src/pages/api/vigilance.ts
Normal file
24
src/pages/api/vigilance.ts
Normal 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' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
82
src/pages/conseils/index.astro
Normal file
82
src/pages/conseils/index.astro
Normal 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>
|
||||||
182
src/pages/departement/[code].astro
Normal file
182
src/pages/departement/[code].astro
Normal 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
128
src/pages/index.astro
Normal 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
36
src/styles/global.css
Normal 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
28
tailwind.config.mjs
Normal 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
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue