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:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user