diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d1276e..292d50d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,23 +1,32 @@ import { useState } from 'react'; import SargassesMap from './components/Map/SargassesMap'; import SpotPanel from './components/SpotPanel/SpotPanel'; -import TimelineSlider from './components/Timeline/TimelineSlider'; +import Topbar from './components/Topbar/Topbar'; +import Legend from './components/Legend/Legend'; import './App.css'; export default function App() { const [selectedSpot, setSelectedSpot] = useState(null); const [activeStep, setActiveStep] = useState('now'); + const [flyToTarget, setFlyToTarget] = useState(null); return (
- + + + +
+ + Zone observée +
+
+ + Prévision dérive +
+
+
+ + OK +
+
+ + Risque +
+
+ + Impact +
+ + ); +} diff --git a/frontend/src/components/Map/SargassesMap.jsx b/frontend/src/components/Map/SargassesMap.jsx index 8ba7797..af8dc30 100644 --- a/frontend/src/components/Map/SargassesMap.jsx +++ b/frontend/src/components/Map/SargassesMap.jsx @@ -3,17 +3,19 @@ import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/mapl import 'maplibre-gl/dist/maplibre-gl.css'; import { api } from '../../api/client'; -const INITIAL_VIEW = { longitude: -61.2, latitude: 15.5, zoom: 7 }; +const INITIAL_VIEW = { longitude: -61.55, latitude: 16.25, zoom: 9 }; -// Styles des couches selon le type (observation vs prédiction) -const layerStyles = (isPrediction) => ({ +const LEVEL_COLOR = { low: '#22c55e', medium: '#eab308', high: '#ef4444' }; + +const makeLayerStyles = (isPrediction, opacity) => ({ 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, + 'fill-opacity': (isPrediction ? 0.25 : 0.35) * opacity, + 'fill-opacity-transition': { duration: 350, delay: 0 }, }, }, line: { @@ -23,30 +25,63 @@ const layerStyles = (isPrediction) => ({ paint: { 'line-color': isPrediction ? '#6366f1' : '#d97706', 'line-width': 2, + 'line-opacity': opacity, + 'line-opacity-transition': { duration: 350, delay: 0 }, 'line-dasharray': isPrediction ? [4, 3] : [1], }, }, }); -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 }) { +export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) { const mapRef = useRef(null); - const [spots, setSpots] = useState([]); - const [obsGeoJSON, setObsGeoJSON] = useState(null); - const [foreGeoJSON, setForeGeoJSON] = useState(null); - const [viewState, setViewState] = useState(INITIAL_VIEW); + 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); - // Spots côtiers : chargement unique + // Spots : chargement unique useEffect(() => { api.spots.list() .then(data => setSpots(Array.isArray(data) ? data : (data['hydra:member'] ?? data.member ?? []))) .catch(console.error); }, []); + // Scores des spots pour colorier les marqueurs (chargement progressif) + useEffect(() => { + if (spots.length === 0) return; + spots.forEach(s => { + api.spots.score(s.id) + .then(d => { + const level = d?.score?.level; + if (level) setSpotScores(prev => ({ ...prev, [s.id]: level })); + }) + .catch(() => {}); + }); + }, [spots]); + + // Fade transition quand on change d'étape + useEffect(() => { + setLayerOpacity(0); + const t = setTimeout(() => setLayerOpacity(1), 60); + return () => clearTimeout(t); + }, [activeStep]); + + // flyTo quand une île est sélectionnée + useEffect(() => { + if (!flyToTarget) return; + const map = mapRef.current?.getMap(); + if (!map) return; + map.flyTo({ + center: flyToTarget.center, + zoom: flyToTarget.zoom, + speed: 1.4, + }); + }, [flyToTarget]); + const loadLayers = useCallback(() => { const map = mapRef.current?.getMap(); if (!map) return; @@ -56,13 +91,11 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep .map(v => v.toFixed(4)).join(','); 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 => { @@ -89,6 +122,9 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep return null; }; + const OBS_STYLES = makeLayerStyles(false, layerOpacity); + const FORE_STYLES = makeLayerStyles(true, layerOpacity); + return ( - + - {/* Couche observations (now) */} {obsGeoJSON && ( @@ -109,7 +144,6 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep )} - {/* Couche prédictions (H+6/12/24/48) */} {foreGeoJSON && ( @@ -117,11 +151,15 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep )} - {/* Marqueurs spots */} {spots.map(spot => { - const coords = getCoords(spot); + const coords = getCoords(spot); if (!coords) return null; + const isSelected = spot.id === selectedSpotId; + const level = spotScores[spot.id]; + const baseColor = level ? LEVEL_COLOR[level] : '#cbd5e1'; + const color = isSelected ? '#38bdf8' : baseColor; + const size = isSelected ? 16 : 10; return ( diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css index 0b33d44..d51d680 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.css +++ b/frontend/src/components/SpotPanel/SpotPanel.css @@ -55,21 +55,60 @@ /* Score */ .spot-panel__score { margin-bottom: 16px; } -.spot-panel__level { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-radius: 10px; +/* Gauge */ +.spot-panel__gauge { + padding: 14px 16px 12px; + border-radius: 12px; margin-bottom: 12px; - font-weight: 600; } -.level--ok { background: #14532d; color: #86efac; } -.level--risk { background: #78350f; color: #fcd34d; } -.level--impact { background: #7f1d1d; color: #fca5a5; } +.gauge--low { background: rgba(20, 83, 45, 0.45); } +.gauge--medium { background: rgba(120, 53, 15, 0.45); } +.gauge--high { background: rgba(127, 29, 29, 0.45); } -.spot-panel__level-label { font-size: 18px; } -.spot-panel__level-value { font-size: 14px; opacity: .8; } +.spot-panel__gauge-top { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; +} + +.spot-panel__gauge-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; +} +.gauge--low .spot-panel__gauge-label { color: #86efac; } +.gauge--medium .spot-panel__gauge-label { color: #fcd34d; } +.gauge--high .spot-panel__gauge-label { color: #fca5a5; } + +.spot-panel__gauge-score { + font-size: 30px; + font-weight: 700; + color: #f1f5f9; + line-height: 1; +} +.spot-panel__gauge-max { + font-size: 13px; + font-weight: 400; + color: #64748b; + margin-left: 2px; +} + +.spot-panel__gauge-track { + height: 5px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} +.spot-panel__gauge-fill { + height: 100%; + border-radius: 3px; + transition: width .6s cubic-bezier(0.4, 0, 0.2, 1); +} +.gauge--low .spot-panel__gauge-fill { background: #22c55e; } +.gauge--medium .spot-panel__gauge-fill { background: #eab308; } +.gauge--high .spot-panel__gauge-fill { background: #ef4444; } .spot-panel__details { display: grid; diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx index b5c8855..ce6a3d5 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.jsx +++ b/frontend/src/components/SpotPanel/SpotPanel.jsx @@ -4,8 +4,7 @@ import usePushSubscription from '../../hooks/usePushSubscription'; 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 TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' }; const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' }; @@ -67,9 +66,20 @@ export default function SpotPanel({ spot, activeStep, onClose }) { ?

Aucune donnée pour cet horizon.

: ( <> -
- {LEVEL_LABEL[lvl] ?? lvl} - {displayScore.value} / 100 +
+
+ {LEVEL_LABEL[lvl] ?? lvl} + + {displayScore.value} + /100 + +
+
+
+
diff --git a/frontend/src/components/Timeline/TimelineSlider.css b/frontend/src/components/Timeline/TimelineSlider.css index d1b5189..d9a2d37 100644 --- a/frontend/src/components/Timeline/TimelineSlider.css +++ b/frontend/src/components/Timeline/TimelineSlider.css @@ -1,59 +1,43 @@ .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; + align-items: center; + gap: 2px; +} + +.timeline__date { + padding: 3px 10px 3px 0; + color: #64748b; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + letter-spacing: 0.02em; + border-right: 1px solid #334155; + margin-right: 4px; +} + +.timeline__separator { + display: none; /* date + border-right fait le même effet */ } .timeline__step { - padding: 6px 14px; + padding: 4px 10px; border: none; - border-radius: 8px; + border-radius: 7px; background: transparent; color: #94a3b8; - font-size: 13px; + font-size: 12px; font-weight: 500; cursor: pointer; transition: background .15s, color .15s; white-space: nowrap; } -.timeline__date { - padding: 6px 10px; - color: #64748b; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - letter-spacing: 0.02em; -} - -.timeline__separator { - width: 1px; - background: #334155; - margin: 4px 0; -} - -.timeline__step:hover { background: #334155; color: #f1f5f9; } +.timeline__step:hover { background: #1e293b; color: #f1f5f9; } .timeline__step--active { background: #0ea5e9; color: #fff; } -/* ── Mobile ── */ @media (max-width: 480px) { - .timeline { - top: 8px; - padding: 3px; - gap: 2px; - } - .timeline__step { - padding: 5px 9px; - font-size: 12px; - } + .timeline__step { padding: 4px 7px; font-size: 11px; } + .timeline__date { display: none; } } diff --git a/frontend/src/components/Topbar/Topbar.css b/frontend/src/components/Topbar/Topbar.css new file mode 100644 index 0000000..e461c40 --- /dev/null +++ b/frontend/src/components/Topbar/Topbar.css @@ -0,0 +1,81 @@ +.topbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 52px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + background: rgba(15, 23, 42, 0.88); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(51, 65, 85, 0.5); + z-index: 20; +} + +/* ── Brand ── */ +.topbar__brand { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.topbar__logo { font-size: 18px; } + +.topbar__title { + font-size: 14px; + font-weight: 700; + color: #f1f5f9; + letter-spacing: -0.01em; + white-space: nowrap; +} + +/* ── Islands nav ── */ +.topbar__islands { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + justify-content: center; + overflow-x: auto; + scrollbar-width: none; + min-width: 0; +} +.topbar__islands::-webkit-scrollbar { display: none; } + +.topbar__island-btn { + padding: 4px 12px; + border: 1px solid #334155; + border-radius: 20px; + background: transparent; + color: #94a3b8; + font-size: 12px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; +} +.topbar__island-btn:hover { + background: #1e293b; + color: #f1f5f9; + border-color: #475569; +} + +/* ── Timeline slot ── */ +.topbar__timeline { + flex-shrink: 0; +} + +/* ── Mobile ── */ +@media (max-width: 640px) { + .topbar__title { display: none; } + .topbar { padding: 0 10px; gap: 8px; } + .topbar__island-btn { padding: 4px 9px; font-size: 11px; } +} + +@media (max-width: 420px) { + .topbar__islands { display: none; } +} diff --git a/frontend/src/components/Topbar/Topbar.jsx b/frontend/src/components/Topbar/Topbar.jsx new file mode 100644 index 0000000..616dcd7 --- /dev/null +++ b/frontend/src/components/Topbar/Topbar.jsx @@ -0,0 +1,37 @@ +import TimelineSlider from '../Timeline/TimelineSlider'; +import './Topbar.css'; + +const ISLANDS = [ + { key: 'guadeloupe', label: 'Guadeloupe', center: [-61.55, 16.25], zoom: 9 }, + { key: 'martinique', label: 'Martinique', center: [-61.02, 14.65], zoom: 9 }, + { key: 'barbade', label: 'Barbade', center: [-59.55, 13.18], zoom: 10 }, + { key: 'saint-martin', label: 'St-Martin', center: [-63.07, 18.07], zoom: 11 }, + { key: 'dominique', label: 'Dominique', center: [-61.37, 15.41], zoom: 10 }, +]; + +export default function Topbar({ activeStep, onStepChange, onIslandSelect }) { + return ( +
+
+ + Radar Sargasses +
+ + + +
+ +
+
+ ); +}