feat: refonte UX front — topbar, navigation île, marqueurs colorés, gauge score, fade layers
- Topbar glassmorphism : logo + boutons île (Guadeloupe/Martinique/Barbade/St-Martin/Dominique) + timeline intégrée - flyTo() animé par île, vue initiale centrée sur Guadeloupe - Marqueurs spots colorés par niveau d'impact (vert/jaune/rouge) chargés progressivement - Score remplacé par un gauge : valeur en grand + barre de progression animée - Légende flottante bas-gauche (observations / prévisions / niveaux) - Transition fade 350ms sur les layers MapLibre au changement d'horizon temporel - NavigationControl déplacé en bas à droite Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,32 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import SargassesMap from './components/Map/SargassesMap';
|
import SargassesMap from './components/Map/SargassesMap';
|
||||||
import SpotPanel from './components/SpotPanel/SpotPanel';
|
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';
|
import './App.css';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [selectedSpot, setSelectedSpot] = useState(null);
|
const [selectedSpot, setSelectedSpot] = useState(null);
|
||||||
const [activeStep, setActiveStep] = useState('now');
|
const [activeStep, setActiveStep] = useState('now');
|
||||||
|
const [flyToTarget, setFlyToTarget] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<TimelineSlider activeStep={activeStep} onChange={setActiveStep} />
|
<Topbar
|
||||||
|
activeStep={activeStep}
|
||||||
|
onStepChange={setActiveStep}
|
||||||
|
onIslandSelect={setFlyToTarget}
|
||||||
|
/>
|
||||||
|
|
||||||
<SargassesMap
|
<SargassesMap
|
||||||
onSpotSelect={setSelectedSpot}
|
onSpotSelect={setSelectedSpot}
|
||||||
selectedSpotId={selectedSpot?.id}
|
selectedSpotId={selectedSpot?.id}
|
||||||
activeStep={activeStep}
|
activeStep={activeStep}
|
||||||
|
flyToTarget={flyToTarget}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Legend />
|
||||||
|
|
||||||
<SpotPanel
|
<SpotPanel
|
||||||
spot={selectedSpot}
|
spot={selectedSpot}
|
||||||
activeStep={activeStep}
|
activeStep={activeStep}
|
||||||
|
|||||||
54
frontend/src/components/Legend/Legend.css
Normal file
54
frontend/src/components/Legend/Legend.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(51, 65, 85, 0.5);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend__swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.legend__swatch--obs { background: #f59e0b; opacity: 0.8; }
|
||||||
|
.legend__swatch--fore { background: #818cf8; opacity: 0.8; }
|
||||||
|
|
||||||
|
.legend__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #334155;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend__dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.legend__dot--low { background: #22c55e; }
|
||||||
|
.legend__dot--medium { background: #eab308; }
|
||||||
|
.legend__dot--high { background: #ef4444; }
|
||||||
|
|
||||||
|
/* Mobile: déplacer si spotPanel est visible */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.legend { bottom: auto; top: 60px; left: 10px; }
|
||||||
|
}
|
||||||
29
frontend/src/components/Legend/Legend.jsx
Normal file
29
frontend/src/components/Legend/Legend.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import './Legend.css';
|
||||||
|
|
||||||
|
export default function Legend() {
|
||||||
|
return (
|
||||||
|
<aside className="legend" aria-label="Légende">
|
||||||
|
<div className="legend__item">
|
||||||
|
<span className="legend__swatch legend__swatch--obs" />
|
||||||
|
<span>Zone observée</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__item">
|
||||||
|
<span className="legend__swatch legend__swatch--fore" />
|
||||||
|
<span>Prévision dérive</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__divider" />
|
||||||
|
<div className="legend__item">
|
||||||
|
<span className="legend__dot legend__dot--low" />
|
||||||
|
<span>OK</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__item">
|
||||||
|
<span className="legend__dot legend__dot--medium" />
|
||||||
|
<span>Risque</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__item">
|
||||||
|
<span className="legend__dot legend__dot--high" />
|
||||||
|
<span>Impact</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,17 +3,19 @@ import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/mapl
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { api } from '../../api/client';
|
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 LEVEL_COLOR = { low: '#22c55e', medium: '#eab308', high: '#ef4444' };
|
||||||
const layerStyles = (isPrediction) => ({
|
|
||||||
|
const makeLayerStyles = (isPrediction, opacity) => ({
|
||||||
fill: {
|
fill: {
|
||||||
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
|
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: isPrediction ? 'forecasts' : 'observations',
|
source: isPrediction ? 'forecasts' : 'observations',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': isPrediction ? '#818cf8' : '#f59e0b',
|
'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: {
|
line: {
|
||||||
@@ -23,30 +25,63 @@ const layerStyles = (isPrediction) => ({
|
|||||||
paint: {
|
paint: {
|
||||||
'line-color': isPrediction ? '#6366f1' : '#d97706',
|
'line-color': isPrediction ? '#6366f1' : '#d97706',
|
||||||
'line-width': 2,
|
'line-width': 2,
|
||||||
|
'line-opacity': opacity,
|
||||||
|
'line-opacity-transition': { duration: 350, delay: 0 },
|
||||||
'line-dasharray': isPrediction ? [4, 3] : [1],
|
'line-dasharray': isPrediction ? [4, 3] : [1],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const OBS_STYLES = layerStyles(false);
|
|
||||||
const FORE_STYLES = layerStyles(true);
|
|
||||||
|
|
||||||
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
|
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 mapRef = useRef(null);
|
||||||
const [spots, setSpots] = useState([]);
|
const [spots, setSpots] = useState([]);
|
||||||
|
const [spotScores, setSpotScores] = useState({});
|
||||||
const [obsGeoJSON, setObsGeoJSON] = useState(null);
|
const [obsGeoJSON, setObsGeoJSON] = useState(null);
|
||||||
const [foreGeoJSON, setForeGeoJSON] = useState(null);
|
const [foreGeoJSON, setForeGeoJSON] = useState(null);
|
||||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||||
|
const [layerOpacity, setLayerOpacity] = useState(1);
|
||||||
|
|
||||||
// Spots côtiers : chargement unique
|
// Spots : chargement unique
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.spots.list()
|
api.spots.list()
|
||||||
.then(data => setSpots(Array.isArray(data) ? data : (data['hydra:member'] ?? data.member ?? [])))
|
.then(data => setSpots(Array.isArray(data) ? data : (data['hydra:member'] ?? data.member ?? [])))
|
||||||
.catch(console.error);
|
.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 loadLayers = useCallback(() => {
|
||||||
const map = mapRef.current?.getMap();
|
const map = mapRef.current?.getMap();
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -56,13 +91,11 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
.map(v => v.toFixed(4)).join(',');
|
.map(v => v.toFixed(4)).join(',');
|
||||||
|
|
||||||
if (activeStep === 'now') {
|
if (activeStep === 'now') {
|
||||||
// Observations réelles
|
|
||||||
api.observations.list(bbox)
|
api.observations.list(bbox)
|
||||||
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
|
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
setForeGeoJSON(null);
|
setForeGeoJSON(null);
|
||||||
} else {
|
} else {
|
||||||
// Forecasts pour cet horizon
|
|
||||||
const horizon = horizonToInt(activeStep);
|
const horizon = horizonToInt(activeStep);
|
||||||
api.forecasts.list(bbox, horizon)
|
api.forecasts.list(bbox, horizon)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -89,6 +122,9 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OBS_STYLES = makeLayerStyles(false, layerOpacity);
|
||||||
|
const FORE_STYLES = makeLayerStyles(true, layerOpacity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -99,9 +135,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
|
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
|
||||||
style={{ width: '100%', height: '100vh' }}
|
style={{ width: '100%', height: '100vh' }}
|
||||||
>
|
>
|
||||||
<NavigationControl position="top-right" />
|
<NavigationControl position="bottom-right" />
|
||||||
|
|
||||||
{/* Couche observations (now) */}
|
|
||||||
{obsGeoJSON && (
|
{obsGeoJSON && (
|
||||||
<Source id="observations" type="geojson" data={obsGeoJSON}>
|
<Source id="observations" type="geojson" data={obsGeoJSON}>
|
||||||
<Layer {...OBS_STYLES.fill} />
|
<Layer {...OBS_STYLES.fill} />
|
||||||
@@ -109,7 +144,6 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Couche prédictions (H+6/12/24/48) */}
|
|
||||||
{foreGeoJSON && (
|
{foreGeoJSON && (
|
||||||
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
|
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
|
||||||
<Layer {...FORE_STYLES.fill} />
|
<Layer {...FORE_STYLES.fill} />
|
||||||
@@ -117,11 +151,15 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Marqueurs spots */}
|
|
||||||
{spots.map(spot => {
|
{spots.map(spot => {
|
||||||
const coords = getCoords(spot);
|
const coords = getCoords(spot);
|
||||||
if (!coords) return null;
|
if (!coords) return null;
|
||||||
|
|
||||||
const isSelected = spot.id === selectedSpotId;
|
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 (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
@@ -134,13 +172,14 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep
|
|||||||
<div
|
<div
|
||||||
title={spot.name}
|
title={spot.name}
|
||||||
style={{
|
style={{
|
||||||
width: isSelected ? 16 : 10,
|
width: size,
|
||||||
height: isSelected ? 16 : 10,
|
height: size,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: isSelected ? '#38bdf8' : '#fff',
|
background: color,
|
||||||
border: `2px solid ${isSelected ? '#0ea5e9' : '#94a3b8'}`,
|
border: `2px solid ${isSelected ? '#0ea5e9' : 'rgba(0,0,0,0.3)'}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all .15s',
|
transition: 'all .2s',
|
||||||
|
boxShadow: isSelected ? `0 0 0 3px rgba(14,165,233,.35)` : 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
@@ -55,21 +55,60 @@
|
|||||||
/* Score */
|
/* Score */
|
||||||
.spot-panel__score { margin-bottom: 16px; }
|
.spot-panel__score { margin-bottom: 16px; }
|
||||||
|
|
||||||
.spot-panel__level {
|
/* Gauge */
|
||||||
display: flex;
|
.spot-panel__gauge {
|
||||||
align-items: center;
|
padding: 14px 16px 12px;
|
||||||
justify-content: space-between;
|
border-radius: 12px;
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.level--ok { background: #14532d; color: #86efac; }
|
.gauge--low { background: rgba(20, 83, 45, 0.45); }
|
||||||
.level--risk { background: #78350f; color: #fcd34d; }
|
.gauge--medium { background: rgba(120, 53, 15, 0.45); }
|
||||||
.level--impact { background: #7f1d1d; color: #fca5a5; }
|
.gauge--high { background: rgba(127, 29, 29, 0.45); }
|
||||||
|
|
||||||
.spot-panel__level-label { font-size: 18px; }
|
.spot-panel__gauge-top {
|
||||||
.spot-panel__level-value { font-size: 14px; opacity: .8; }
|
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 {
|
.spot-panel__details {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import usePushSubscription from '../../hooks/usePushSubscription';
|
|||||||
import './SpotPanel.css';
|
import './SpotPanel.css';
|
||||||
|
|
||||||
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
|
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' };
|
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
|
||||||
@@ -67,9 +66,20 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
|||||||
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
|
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
|
<div className={`spot-panel__gauge gauge--${lvl}`}>
|
||||||
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
|
<div className="spot-panel__gauge-top">
|
||||||
<span className="spot-panel__level-value">{displayScore.value} / 100</span>
|
<span className="spot-panel__gauge-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
|
||||||
|
<span className="spot-panel__gauge-score">
|
||||||
|
{displayScore.value}
|
||||||
|
<span className="spot-panel__gauge-max">/100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spot-panel__gauge-track">
|
||||||
|
<div
|
||||||
|
className="spot-panel__gauge-fill"
|
||||||
|
style={{ width: `${displayScore.value}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="spot-panel__details">
|
<dl className="spot-panel__details">
|
||||||
|
|||||||
@@ -1,59 +1,43 @@
|
|||||||
.timeline {
|
.timeline {
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
background: #1e293b;
|
gap: 2px;
|
||||||
border: 1px solid #334155;
|
}
|
||||||
border-radius: 12px;
|
|
||||||
padding: 4px;
|
.timeline__date {
|
||||||
z-index: 10;
|
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 {
|
.timeline__step {
|
||||||
padding: 6px 14px;
|
padding: 4px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, color .15s;
|
transition: background .15s, color .15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.timeline__date {
|
.timeline__step:hover { background: #1e293b; color: #f1f5f9; }
|
||||||
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--active {
|
.timeline__step--active {
|
||||||
background: #0ea5e9;
|
background: #0ea5e9;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.timeline {
|
.timeline__step { padding: 4px 7px; font-size: 11px; }
|
||||||
top: 8px;
|
.timeline__date { display: none; }
|
||||||
padding: 3px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.timeline__step {
|
|
||||||
padding: 5px 9px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
81
frontend/src/components/Topbar/Topbar.css
Normal file
81
frontend/src/components/Topbar/Topbar.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
37
frontend/src/components/Topbar/Topbar.jsx
Normal file
37
frontend/src/components/Topbar/Topbar.jsx
Normal file
@@ -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 (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar__brand">
|
||||||
|
<span className="topbar__logo" aria-hidden="true">🌊</span>
|
||||||
|
<span className="topbar__title">Radar Sargasses</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="topbar__islands" aria-label="Navigation par île">
|
||||||
|
{ISLANDS.map(island => (
|
||||||
|
<button
|
||||||
|
key={island.key}
|
||||||
|
className="topbar__island-btn"
|
||||||
|
onClick={() => onIslandSelect({ center: island.center, zoom: island.zoom, _t: Date.now() })}
|
||||||
|
>
|
||||||
|
{island.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar__timeline">
|
||||||
|
<TimelineSlider activeStep={activeStep} onChange={onStepChange} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user