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

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

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

View file

@ -0,0 +1,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}`);