Phase 2 : dérive, ImpactScore, slider temporel

Backend :
- OpenMeteoClient : vent horaire 72h (gratuit, sans clé)
- DriftSimulationService : échantillonnage ST_GeneratePoints,
  déplacement itératif Stokes 3%, reconstruction ST_ConcaveHull,
  4 horizons H+6/12/24/48, confiance décroissante
- ImpactScoreService : score 4 composantes (distance/densité/
  vitesse/tendance), cache Redis TTL 3h, invalidation à chaque calcul
- ComputeForecastsCommand : traite les observations sans forecast
- SpotScoreController : score courant + horizons depuis forecasts

Frontend :
- TimelineSlider : navigation Now/+6h/+12h/+24h/+48h
- SargassesMap : couche observations (orange) vs forecasts (violet)
  rechargée à chaque changement d'étape
- SpotPanel : affichage score par horizon actif + indicateur confiance
- Build → backend/public/spa/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:23:21 -04:00
parent 0c1deb93cb
commit 94bb6f5e8c
18 changed files with 983 additions and 186 deletions

View File

@@ -3,73 +3,84 @@ import { api } from '../../api/client';
import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
export default function SpotPanel({ spot, onClose }) {
const [score, setScore] = useState(undefined); // undefined = loading
const [error, setError] = useState(null);
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
export default function SpotPanel({ spot, activeStep, onClose }) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(null);
useEffect(() => {
if (!spot) return;
setScore(undefined);
setData(undefined);
setError(null);
api.spots.score(spot.id)
.then(data => setScore(data))
.then(d => setData(d))
.catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]);
if (!spot) return null;
const icon = TYPE_ICON[spot.type] ?? '📍';
const lvl = score?.score?.level;
// Score à afficher selon l'étape active
const displayScore = activeStep === 'now'
? data?.score
: (data?.horizons?.[activeStep] ? {
value: data.horizons[activeStep].score,
level: data.horizons[activeStep].level,
distance: data.horizons[activeStep].distanceKm,
confidence: data.horizons[activeStep].confidence,
} : null);
const lvl = displayScore?.level;
return (
<div className="spot-panel">
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button>
<div className="spot-panel__header">
<span className="spot-panel__icon">{icon}</span>
<span className="spot-panel__icon">{TYPE_ICON[spot.type] ?? '📍'}</span>
<div>
<h2 className="spot-panel__name">{spot.name}</h2>
<p className="spot-panel__region">{spot.region}</p>
</div>
</div>
<div className="spot-panel__score">
{score === undefined && <p className="spot-panel__loading">Chargement</p>}
<div className="spot-panel__horizon-label">
{HORIZON_LABELS[activeStep] ?? activeStep}
{activeStep !== 'now' && displayScore?.confidence != null && (
<span className="spot-panel__confidence">
Confiance : {Math.round(displayScore.confidence * 100)}%
</span>
)}
</div>
<div className="spot-panel__score">
{data === undefined && <p className="spot-panel__loading">Chargement</p>}
{error && <p className="spot-panel__error">{error}</p>}
{score !== undefined && !error && (
score.score === null
? <p className="spot-panel__no-data">Aucune donnée disponible pour ce spot.</p>
{data !== undefined && !error && (
displayScore === null
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
: (
<>
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
<span className="spot-panel__level-value">{score.score.value} / 100</span>
<span className="spot-panel__level-value">{displayScore.value} / 100</span>
</div>
<dl className="spot-panel__details">
{score.score.distance != null && (
<>
<dt>Distance sargasses</dt>
<dd>{score.score.distance.toFixed(1)} km</dd>
</>
{displayScore.distance != null && (
<><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></>
)}
{score.score.trend && (
<>
<dt>Tendance</dt>
<dd>{score.score.trend}</dd>
</>
{activeStep === 'now' && displayScore.trend && (
<><dt>Tendance</dt><dd>{displayScore.trend}</dd></>
)}
{score.score.computedAt && (
<>
<dt>Calculé le</dt>
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
</>
{activeStep === 'now' && displayScore.computedAt && (
<><dt>Calculé le</dt>
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
)}
</dl>
</>
@@ -83,23 +94,19 @@ export default function SpotPanel({ spot, onClose }) {
}
function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false);
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const submit = async (hasSeaweed) => {
if (loading || sent) return;
setLoading(true);
const coords = spot.geometry?.coordinates;
if (!coords) return;
await api.feedback.create({
lat: coords[1],
lng: coords[0],
hasSeaweed,
coastalPointId: spot.id,
}).catch(console.error);
if (coords) {
await api.feedback.create({
lat: coords[1], lng: coords[0],
hasSeaweed, coastalPointId: spot.id,
}).catch(console.error);
}
setLoading(false);
setSent(true);
};