feat: phase A — backup B2, healthcheck /api/status, vector tiles Tippecanoe

- A1: scripts/backup-postgres.sh — pg_dump quotidien compressé → Backblaze B2 (rclone), rétention 30j
- A2: StatusController retourne HTTP 503 + healthy/alertReason si dernière observation > 6h
- A4: Dockerfile installe tippecanoe, GenerateTilesCommand génère 5 tilesets (obs + h6/12/24/48), Caddyfile sert /tiles/* sans fallback SPA, SargassesMap.jsx passe en sources vector tiles statiques
- chore: backend/public/assets/ ajouté au .gitignore (build artifacts)
- chore: setup vitest frontend + ImpactScoreServiceTest (session précédente)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-10 03:40:32 -04:00
parent 1d8771cdcb
commit 9712cb54db
20 changed files with 1653 additions and 872 deletions

View File

@@ -66,36 +66,36 @@ function cartoonizeStyle(style) {
return { ...style, layers };
}
// Filtre : affiche uniquement les features avec sargasses (afai > 0),
// ou si la propriété est absente (données legacy sans afaiIndex).
const OBS_FILTER = ['any', ['!', ['has', 'afai']], ['>', ['get', 'afai'], 0]];
// ── Layer style factories ──────────────────────────────────────────────────
const makeLayerStyles = (isPrediction, opacity) => ({
fill: {
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
type: 'fill',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
// Prévisions : même couleur sargasses que l'observation, juste atténuée
'fill-color': '#f4a020',
'fill-opacity': (isPrediction ? 0.20 : 0.50) * opacity,
'fill-opacity-transition': { duration: 350 },
const makeLayerStyles = (isPrediction, opacity) => {
const sourceKey = isPrediction ? 'forecasts' : 'observations';
return {
fill: {
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
type: 'fill',
source: sourceKey,
'source-layer': sourceKey,
paint: {
'fill-color': '#f4a020',
'fill-opacity': (isPrediction ? 0.20 : 0.50) * opacity,
'fill-opacity-transition': { duration: 350 },
},
},
},
line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
type: 'line',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'line-color': '#c47a10',
'line-width': 2.5,
'line-opacity': opacity * (isPrediction ? 0.65 : 0.85),
'line-opacity-transition': { duration: 350 },
...(isPrediction ? { 'line-dasharray': [6, 4] } : {}),
line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
type: 'line',
source: sourceKey,
'source-layer': sourceKey,
paint: {
'line-color': '#c47a10',
'line-width': 2.5,
'line-opacity': opacity * (isPrediction ? 0.65 : 0.85),
'line-opacity-transition': { duration: 350 },
...(isPrediction ? { 'line-dasharray': [6, 4] } : {}),
},
},
},
});
};
};
// ── SVG Markers ───────────────────────────────────────────────────────────
function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
@@ -148,19 +148,17 @@ function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
);
}
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
const mapRef = useRef(null);
const [spots, setSpots] = useState([]);
const [spotScores, setSpotScores] = useState({});
const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [foreGeoJSON, setForeGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [layerOpacity, setLayerOpacity] = useState(1);
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
const [dataDate, setDataDate] = useState(null);
const tilesOrigin = window.location.origin;
// Fraîcheur des données
useEffect(() => {
api.status.get()
@@ -215,47 +213,11 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
mapRef.current?.getMap()?.flyTo({ center: flyToTarget.center, zoom: flyToTarget.zoom, speed: 1.4 });
}, [flyToTarget]);
const loadLayers = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(4)).join(',');
if (activeStep === 'now') {
api.observations.list(bbox)
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
.catch(console.error);
setForeGeoJSON(null);
} else {
api.forecasts.list(bbox, horizonToInt(activeStep))
.then(data => { setForeGeoJSON(toFeatureCollection(data.items)); setObsGeoJSON(null); })
.catch(console.error);
}
}, [activeStep]);
useEffect(() => { loadLayers(); }, [loadLayers]);
const toFeatureCollection = (items = []) => {
const features = (items ?? []).filter(i => i.geometry)
.map(i => ({
type: 'Feature',
geometry: i.geometry,
// Ne pas injecter afai si absent : MapLibre traite null comme "has=true"
// ce qui ferait échouer le filtre > 0 et masquerait le polygone.
properties: typeof i.afaiIndex === 'number' ? { afai: i.afaiIndex } : {},
}));
return features.length ? { type: 'FeatureCollection', features } : null;
};
const getCoords = (spot) =>
spot.longitude != null && spot.latitude != null
? { lng: spot.longitude, lat: spot.latitude }
: null;
const handleMapLoad = useCallback(() => {
loadLayers();
}, [loadLayers]);
const OBS_STYLES = makeLayerStyles(false, layerOpacity);
const FORE_STYLES = makeLayerStyles(true, layerOpacity);
@@ -264,8 +226,6 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
ref={mapRef}
{...viewState}
onMove={e => setViewState(e.viewState)}
onMoveEnd={loadLayers}
onLoad={handleMapLoad}
onClick={() => onSpotSelect(null)}
mapStyle={mapStyle}
style={{ width: '100%', height: '100vh' }}
@@ -280,15 +240,28 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
</div>
)}
{obsGeoJSON && (
<Source id="observations" type="geojson" data={obsGeoJSON}>
{activeStep === 'now' && (
<Source
id="observations"
type="vector"
tiles={[`${tilesOrigin}/tiles/observations/{z}/{x}/{y}.pbf`]}
minzoom={5}
maxzoom={12}
>
<Layer {...OBS_STYLES.fill} />
<Layer {...OBS_STYLES.line} />
</Source>
)}
{foreGeoJSON && (
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
{activeStep !== 'now' && (
<Source
key={activeStep}
id="forecasts"
type="vector"
tiles={[`${tilesOrigin}/tiles/forecasts/${activeStep}/{z}/{x}/{y}.pbf`]}
minzoom={5}
maxzoom={12}
>
<Layer {...FORE_STYLES.fill} />
<Layer {...FORE_STYLES.line} />
</Source>

View File

@@ -1,14 +1,8 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import usePushSubscription from '../../hooks/usePushSubscription';
import { scoreLabel, computeEta, etaLabel, ETA_BUFFER_KM } from '../../utils/scoring';
import './SpotPanel.css';
function scoreLabel(value, level, distance) {
if (level === 'high') return 'Impact';
if (level === 'medium') return 'Risque';
if (value >= 15 && distance != null && distance < 100) return 'Veille';
return 'OK';
}
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
const TREND_CONFIG = {
@@ -17,27 +11,6 @@ const TREND_CONFIG = {
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
};
const ETA_BUFFER_KM = 5;
function computeEta(score, horizons) {
if (score?.distance != null && score.distance < ETA_BUFFER_KM) return 0;
for (const h of [6, 12, 24, 48]) {
const horizon = horizons?.[`h${h}`];
if (horizon?.distanceKm != null && horizon.distanceKm < ETA_BUFFER_KM) return h;
}
return null;
}
function etaLabel(etaHours, score, distance) {
if (etaHours === 0) return { icon: '🚨', label: 'Impact en cours', cls: 'eta--now' };
if (etaHours === 6) return { icon: '⚠️', label: 'Impact prévu dans ~6h', cls: 'eta--soon' };
if (etaHours === 12) return { icon: '⚠️', label: 'Impact prévu dans ~12h', cls: 'eta--soon' };
if (etaHours === 24) return { icon: '🕐', label: 'Impact prévu dans ~24h', cls: 'eta--later' };
if (etaHours === 48) return { icon: '🕐', label: 'Impact prévu dans ~48h', cls: 'eta--later' };
if ((score ?? 0) >= 15 && distance != null && distance < 100)
return { icon: '👁', label: 'Sargasses en surveillance', cls: 'eta--watch' };
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
}
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,51 @@
/**
* Fonctions pures de scoring — extraites de SpotPanel pour être testables
* indépendamment du rendu React.
*/
export const ETA_BUFFER_KM = 5;
/**
* Libellé dominant de l'indice de risque.
* @param {number} value Score brut 0-100
* @param {string} level 'low' | 'medium' | 'high'
* @param {number|null} distance Distance km au patch le plus proche
*/
export function scoreLabel(value, level, distance) {
if (level === 'high') return 'Impact';
if (level === 'medium') return 'Risque';
if (value >= 15 && distance != null && distance < 100) return 'Veille';
return 'OK';
}
/**
* Calcule l'horizon (h) auquel un impact est prévu.
* Retourne 0 si impact en cours, null si aucun prévu dans 48h.
* @param {{ distance?: number|null }|null} score
* @param {Record<string, { distanceKm?: number|null }>|null} horizons
*/
export function computeEta(score, horizons) {
if (score?.distance != null && score.distance < ETA_BUFFER_KM) return 0;
for (const h of [6, 12, 24, 48]) {
const horizon = horizons?.[`h${h}`];
if (horizon?.distanceKm != null && horizon.distanceKm < ETA_BUFFER_KM) return h;
}
return null;
}
/**
* Retourne l'objet label/icône/classe CSS pour l'affichage ETA.
* @param {number|null} etaHours
* @param {number|null|undefined} score
* @param {number|null|undefined} distance
*/
export function etaLabel(etaHours, score, distance) {
if (etaHours === 0) return { icon: '🚨', label: 'Impact en cours', cls: 'eta--now' };
if (etaHours === 6) return { icon: '⚠️', label: 'Impact prévu dans ~6h', cls: 'eta--soon' };
if (etaHours === 12) return { icon: '⚠️', label: 'Impact prévu dans ~12h', cls: 'eta--soon' };
if (etaHours === 24) return { icon: '🕐', label: 'Impact prévu dans ~24h', cls: 'eta--later' };
if (etaHours === 48) return { icon: '🕐', label: 'Impact prévu dans ~48h', cls: 'eta--later' };
if ((score ?? 0) >= 15 && distance != null && distance < 100)
return { icon: '👁', label: 'Sargasses en surveillance', cls: 'eta--watch' };
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
}

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import { scoreLabel, computeEta, etaLabel, ETA_BUFFER_KM } from './scoring';
// ─── scoreLabel ───────────────────────────────────────────────────────────────
describe('scoreLabel', () => {
it('retourne "Impact" pour level high, quel que soit le score', () => {
expect(scoreLabel(10, 'high', null)).toBe('Impact');
expect(scoreLabel(0, 'high', 200)).toBe('Impact');
expect(scoreLabel(100,'high', 0 )).toBe('Impact');
});
it('retourne "Risque" pour level medium', () => {
expect(scoreLabel(50, 'medium', 50)).toBe('Risque');
expect(scoreLabel(31, 'medium', null)).toBe('Risque');
});
it('retourne "Veille" si score ≥ 15 ET distance < 100km (level low)', () => {
expect(scoreLabel(15, 'low', 99)).toBe('Veille');
expect(scoreLabel(20, 'low', 0 )).toBe('Veille');
expect(scoreLabel(15, 'low', 100)).toBe('OK'); // distance = seuil exact → non inclus
});
it('retourne "OK" si score < 15 même avec distance proche', () => {
expect(scoreLabel(14, 'low', 50)).toBe('OK');
});
it('retourne "OK" si distance null même avec score ≥ 15', () => {
expect(scoreLabel(20, 'low', null)).toBe('OK');
});
it('retourne "OK" si distance ≥ 100km', () => {
expect(scoreLabel(50, 'low', 100)).toBe('OK');
expect(scoreLabel(80, 'low', 150)).toBe('OK');
});
});
// ─── computeEta ───────────────────────────────────────────────────────────────
describe('computeEta', () => {
it('retourne 0 si les sargasses sont déjà à moins de ETA_BUFFER_KM', () => {
expect(computeEta({ distance: 0 }, null)).toBe(0);
expect(computeEta({ distance: ETA_BUFFER_KM - 0.1 }, null)).toBe(0);
});
it('retourne null si aucun horizon < ETA_BUFFER_KM', () => {
const horizons = {
h6: { distanceKm: 50 },
h12: { distanceKm: 40 },
h24: { distanceKm: 30 },
h48: { distanceKm: 20 },
};
expect(computeEta({ distance: 100 }, horizons)).toBeNull();
});
it('retourne le premier horizon qui passe sous ETA_BUFFER_KM', () => {
const horizons = {
h6: { distanceKm: 50 },
h12: { distanceKm: 50 },
h24: { distanceKm: 3 }, // < 5km
h48: { distanceKm: 0 },
};
expect(computeEta({ distance: 100 }, horizons)).toBe(24);
});
it('retourne h6 si dès le premier horizon', () => {
const horizons = { h6: { distanceKm: 2 } };
expect(computeEta({ distance: 100 }, horizons)).toBe(6);
});
it('gère score null / horizons null sans erreur', () => {
expect(computeEta(null, null)).toBeNull();
expect(computeEta({ distance: null }, null)).toBeNull();
});
it('retourne 0 si distance exactement ETA_BUFFER_KM - epsilon', () => {
expect(computeEta({ distance: ETA_BUFFER_KM - 0.001 }, null)).toBe(0);
});
it('retourne null si distance exactement ETA_BUFFER_KM', () => {
// distance >= ETA_BUFFER_KM → pas d'impact en cours, on regarde les horizons
const horizons = { h6: { distanceKm: 10 }, h12: { distanceKm: 10 }, h24: { distanceKm: 10 }, h48: { distanceKm: 10 } };
expect(computeEta({ distance: ETA_BUFFER_KM }, horizons)).toBeNull();
});
});
// ─── etaLabel ─────────────────────────────────────────────────────────────────
describe('etaLabel', () => {
it('retourne eta--now pour etaHours=0', () => {
const r = etaLabel(0, null, null);
expect(r.cls).toBe('eta--now');
expect(r.label).toBe('Impact en cours');
});
it('retourne eta--soon pour etaHours=6 et 12', () => {
expect(etaLabel(6, null, null).cls).toBe('eta--soon');
expect(etaLabel(12, null, null).cls).toBe('eta--soon');
});
it('retourne eta--later pour etaHours=24 et 48', () => {
expect(etaLabel(24, null, null).cls).toBe('eta--later');
expect(etaLabel(48, null, null).cls).toBe('eta--later');
});
it('retourne eta--watch si score ≥ 15 ET distance < 100 ET etaHours null', () => {
expect(etaLabel(null, 15, 50).cls).toBe('eta--watch');
expect(etaLabel(null, 20, 99).cls).toBe('eta--watch');
});
it('retourne eta--safe si pas d\'impact (fallback)', () => {
expect(etaLabel(null, 0, 200).cls).toBe('eta--safe');
expect(etaLabel(null, 14, 50 ).cls).toBe('eta--safe'); // score < 15
expect(etaLabel(null, 20, 100).cls).toBe('eta--safe'); // distance = 100 → non inclus
expect(etaLabel(null, 20, null).cls).toBe('eta--safe');
});
it('retourne eta--safe si score null (considéré comme 0)', () => {
expect(etaLabel(null, null, 50).cls).toBe('eta--safe');
});
});