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:
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
31
frontend/src/components/Timeline/TimelineSlider.css
Normal file
31
frontend/src/components/Timeline/TimelineSlider.css
Normal 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;
|
||||
}
|
||||
25
frontend/src/components/Timeline/TimelineSlider.jsx
Normal file
25
frontend/src/components/Timeline/TimelineSlider.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user