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

@@ -1,19 +1,26 @@
import { useState } from 'react';
import SargassesMap from './components/Map/SargassesMap';
import SpotPanel from './components/SpotPanel/SpotPanel';
import TimelineSlider from './components/Timeline/TimelineSlider';
import './App.css';
export default function App() {
const [selectedSpot, setSelectedSpot] = useState(null);
const [activeStep, setActiveStep] = useState('now');
return (
<div className="app">
<TimelineSlider activeStep={activeStep} onChange={setActiveStep} />
<SargassesMap
onSpotSelect={setSelectedSpot}
selectedSpotId={selectedSpot?.id}
activeStep={activeStep}
/>
<SpotPanel
spot={selectedSpot}
activeStep={activeStep}
onClose={() => setSelectedSpot(null)}
/>
</div>

View File

@@ -33,6 +33,10 @@ export const api = {
list: (bbox, date, source) => get('/observations', { bbox, date, source }),
get: (id) => get(`/observations/${id}`),
},
forecasts: {
list: (bbox, horizon) => get('/forecasts', { bbox, horizon }),
get: (id) => get(`/forecasts/${id}`),
},
feedback: {
create: (data) => post('/feedback', data),
},

View File

@@ -3,99 +3,88 @@ import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/mapl
import 'maplibre-gl/dist/maplibre-gl.css';
import { api } from '../../api/client';
// Emprise initiale : Antilles
const INITIAL_VIEW = {
longitude: -61.2,
latitude: 15.5,
zoom: 7,
};
const INITIAL_VIEW = { longitude: -61.2, latitude: 15.5, zoom: 7 };
const LEVEL_COLOR = {
low: '#22c55e', // vert
medium: '#f59e0b', // orange
high: '#ef4444', // rouge
};
// Styles des couches MapLibre pour les observations
const OBSERVATION_FILL = {
id: 'observations-fill',
type: 'fill',
source: 'observations',
paint: {
'fill-color': '#f59e0b',
'fill-opacity': 0.35,
// Styles des couches selon le type (observation vs prédiction)
const layerStyles = (isPrediction) => ({
fill: {
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
type: 'fill',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'fill-color': isPrediction ? '#818cf8' : '#f59e0b',
'fill-opacity': isPrediction ? 0.25 : 0.35,
},
},
};
const OBSERVATION_LINE = {
id: 'observations-line',
type: 'line',
source: 'observations',
paint: {
'line-color': '#d97706',
'line-width': 2,
line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
type: 'line',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'line-color': isPrediction ? '#6366f1' : '#d97706',
'line-width': 2,
'line-dasharray': isPrediction ? [4, 3] : [1],
},
},
};
});
export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
const OBS_STYLES = layerStyles(false);
const FORE_STYLES = layerStyles(true);
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep }) {
const mapRef = useRef(null);
const [spots, setSpots] = useState([]);
const [observationGeoJSON, setObs] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [spots, setSpots] = useState([]);
const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [foreGeoJSON, setForeGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
// Charge les spots côtiers une seule fois
// Spots côtiers : chargement unique
useEffect(() => {
api.spots.list()
.then(data => setSpots(data['hydra:member'] ?? data.member ?? []))
.catch(console.error);
}, []);
// Charge les observations quand la carte bouge (debounced)
const loadObservations = useCallback(() => {
const loadLayers = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const bounds = map.getBounds();
const bbox = [
bounds.getWest().toFixed(4),
bounds.getSouth().toFixed(4),
bounds.getEast().toFixed(4),
bounds.getNorth().toFixed(4),
].join(',');
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
.map(v => v.toFixed(4)).join(',');
api.observations.list(bbox)
.then(data => {
if (!data.items?.length) { setObs(null); return; }
if (activeStep === 'now') {
// Observations réelles
api.observations.list(bbox)
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
.catch(console.error);
setForeGeoJSON(null);
} else {
// Forecasts pour cet horizon
const horizon = horizonToInt(activeStep);
api.forecasts.list(bbox, horizon)
.then(data => {
setForeGeoJSON(toFeatureCollection(data.items));
setObsGeoJSON(null);
})
.catch(console.error);
}
}, [activeStep]);
// Regroupe toutes les observations en une FeatureCollection
const features = data.items
.filter(o => o.geometry)
.map(o => ({
type: 'Feature',
geometry: o.geometry,
properties: {
id: o.id,
detectedAt: o.detectedAt,
cloudCoverage: o.cloudCoverage,
coverageArea: o.coverageArea,
},
}));
useEffect(() => { loadLayers(); }, [loadLayers]);
setObs({ type: 'FeatureCollection', features });
})
.catch(console.error);
}, []);
const toFeatureCollection = (items = []) => {
const features = (items ?? [])
.filter(i => i.geometry)
.map(i => ({ type: 'Feature', geometry: i.geometry, properties: {} }));
return features.length ? { type: 'FeatureCollection', features } : null;
};
// Recharge les observations après chaque fin de mouvement
const onMoveEnd = useCallback(() => {
loadObservations();
}, [loadObservations]);
// Extraction des coordonnées depuis la géométrie PostGIS (Point GeoJSON)
const getCoords = (spot) => {
const coords = spot.geometry?.coordinates;
if (!coords) return null;
return { lng: coords[0], lat: coords[1] };
const c = spot.geometry?.coordinates;
return c ? { lng: c[0], lat: c[1] } : null;
};
return (
@@ -103,26 +92,33 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
ref={mapRef}
{...viewState}
onMove={e => setViewState(e.viewState)}
onMoveEnd={onMoveEnd}
onLoad={loadObservations}
onMoveEnd={loadLayers}
onLoad={loadLayers}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
style={{ width: '100%', height: '100vh' }}
>
<NavigationControl position="top-right" />
{/* Couche observations sargasses */}
{observationGeoJSON && (
<Source id="observations" type="geojson" data={observationGeoJSON}>
<Layer {...OBSERVATION_FILL} />
<Layer {...OBSERVATION_LINE} />
{/* Couche observations (now) */}
{obsGeoJSON && (
<Source id="observations" type="geojson" data={obsGeoJSON}>
<Layer {...OBS_STYLES.fill} />
<Layer {...OBS_STYLES.line} />
</Source>
)}
{/* Marqueurs spots côtiers */}
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
{/* Couche prédictions (H+6/12/24/48) */}
{foreGeoJSON && (
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
<Layer {...FORE_STYLES.fill} />
<Layer {...FORE_STYLES.line} />
</Source>
)}
{/* Marqueurs spots */}
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
const isSelected = spot.id === selectedSpotId;
return (

View File

@@ -38,6 +38,20 @@
.spot-panel__name { margin: 0; font-size: 18px; font-weight: 600; }
.spot-panel__region { margin: 2px 0 0; font-size: 13px; color: #94a3b8; }
/* Horizon label */
.spot-panel__horizon-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: .05em;
}
.spot-panel__confidence { font-weight: 400; color: #64748b; }
/* Score */
.spot-panel__score { margin-bottom: 16px; }

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);
};

View File

@@ -0,0 +1,31 @@
.timeline {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 4px;
z-index: 10;
}
.timeline__step {
padding: 6px 14px;
border: none;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background .15s, color .15s;
white-space: nowrap;
}
.timeline__step:hover { background: #334155; color: #f1f5f9; }
.timeline__step--active {
background: #0ea5e9;
color: #fff;
}

View File

@@ -0,0 +1,25 @@
import './TimelineSlider.css';
const STEPS = [
{ value: 'now', label: 'Maintenant' },
{ value: 'h6', label: '+6h' },
{ value: 'h12', label: '+12h' },
{ value: 'h24', label: '+24h' },
{ value: 'h48', label: '+48h' },
];
export default function TimelineSlider({ activeStep, onChange }) {
return (
<div className="timeline">
{STEPS.map((step, i) => (
<button
key={step.value}
className={`timeline__step ${activeStep === step.value ? 'timeline__step--active' : ''}`}
onClick={() => onChange(step.value)}
>
{step.label}
</button>
))}
</div>
);
}