feat: refonte visuelle cartoon — fond OpenFreeMap, marqueurs SVG, légende comic, SpotPanel clair

- Fond cartoon : OpenFreeMap bright transformé (îles #7ec850, océan #3ab5e6, bordure #4a7c2f)
- Overlay vagues SVG subtil sur toute la carte (opacity 6%)
- Marqueurs spots SVG expressifs : ★ (ok), ! (risque), ✕ (impact) — animation bounce sur impact
- Sargasses #f4a020 avec texture dots MapLibre + contour épais 3.5px
- Légende comic : fond #fffbf0, bordure 3px, ombre dure 4px 4px 0 #333, Fredoka One
- SpotPanel blanc cassé avec ombre dure cartoon, gauge coloré pastel (vert/jaune/rouge)
- Topbar #1a1a2e avec boutons bleus ombre dure + hover translate(-2px,-2px)
- Timeline : step actif avec ombre dure cartoon
- Police Fredoka One chargée via Google Fonts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-02 15:25:17 -04:00
parent 1078a3459b
commit 7169dffc67
14 changed files with 1172 additions and 196 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,9 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Radar Sargasses 971</title>
<script type="module" crossorigin src="/assets/index-BGNw_mST.js"></script> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" crossorigin href="/assets/index-B9sgBDR3.css"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Fredoka:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-B_-zKuKT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-RGvCvNm_.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Radar Sargasses 971</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Fredoka:wght@400;500;600&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,9 +1,9 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Fredoka', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a; background: #0f172a;
color: #f1f5f9; color: #1a2a1a;
overflow: hidden; overflow: hidden;
} }
@@ -12,3 +12,15 @@ body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
/* Vagues SVG en overlay sur la mer — très subtil, pointer-events none */
.app__wave-overlay {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='40'%3E%3Cpath d='M0 20 Q20 8 40 20 Q60 32 80 20' fill='none' stroke='white' stroke-width='1.5'/%3E%3C/svg%3E");
background-size: 80px 40px;
background-repeat: repeat;
opacity: 0.06;
pointer-events: none;
z-index: 6;
}

View File

@@ -18,6 +18,8 @@ export default function App() {
onIslandSelect={setFlyToTarget} onIslandSelect={setFlyToTarget}
/> />
<div className="app__wave-overlay" aria-hidden="true" />
<SargassesMap <SargassesMap
onSpotSelect={setSelectedSpot} onSpotSelect={setSelectedSpot}
selectedSpotId={selectedSpot?.id} selectedSpotId={selectedSpot?.id}

View File

@@ -1,54 +1,66 @@
.legend { .legend {
position: absolute; position: absolute;
bottom: 24px; bottom: 48px;
left: 16px; left: 16px;
background: rgba(15, 23, 42, 0.82); background: #fffbf0;
backdrop-filter: blur(12px); border: 3px solid #333;
-webkit-backdrop-filter: blur(12px); border-radius: 16px;
border: 1px solid rgba(51, 65, 85, 0.5); padding: 10px 14px 12px;
border-radius: 10px;
padding: 10px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
z-index: 10; z-index: 10;
box-shadow: 4px 4px 0px #333;
}
.legend__title {
font-family: 'Fredoka One', cursive;
font-size: 13px;
color: #1a1a2e;
margin-bottom: 2px;
letter-spacing: 0.03em;
} }
.legend__item { .legend__item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
font-size: 11px; font-family: 'Fredoka', sans-serif;
color: #94a3b8; font-size: 12px;
font-weight: 500;
color: #333;
white-space: nowrap; white-space: nowrap;
} }
.legend__swatch { .legend__swatch {
width: 14px; width: 16px;
height: 10px; height: 10px;
border-radius: 2px; border-radius: 3px;
border: 2px solid #333;
flex-shrink: 0; flex-shrink: 0;
} }
.legend__swatch--obs { background: #f59e0b; opacity: 0.8; } .legend__swatch--obs { background: #f4a020; }
.legend__swatch--fore { background: #818cf8; opacity: 0.8; } .legend__swatch--fore { background: #818cf8; }
.legend__divider { .legend__divider {
height: 1px; height: 2px;
background: #334155; background: #333;
margin: 2px 0; margin: 2px 0;
border-radius: 1px;
} }
.legend__dot { .legend__dot {
width: 9px; width: 11px;
height: 9px; height: 11px;
border-radius: 50%; border-radius: 50%;
border: 2px solid #fff;
outline: 2px solid #333;
flex-shrink: 0; flex-shrink: 0;
} }
.legend__dot--low { background: #22c55e; } .legend__dot--low { background: #22c55e; }
.legend__dot--medium { background: #eab308; } .legend__dot--medium { background: #f59e0b; }
.legend__dot--high { background: #ef4444; } .legend__dot--high { background: #ef4444; }
/* Mobile: déplacer si spotPanel est visible */
@media (max-width: 480px) { @media (max-width: 480px) {
.legend { bottom: auto; top: 60px; left: 10px; } .legend { bottom: auto; top: 60px; left: 10px; }
} }

View File

@@ -3,6 +3,7 @@ import './Legend.css';
export default function Legend() { export default function Legend() {
return ( return (
<aside className="legend" aria-label="Légende"> <aside className="legend" aria-label="Légende">
<p className="legend__title">Légende</p>
<div className="legend__item"> <div className="legend__item">
<span className="legend__swatch legend__swatch--obs" /> <span className="legend__swatch legend__swatch--obs" />
<span>Zone observée</span> <span>Zone observée</span>

View File

@@ -0,0 +1,24 @@
/* ── Spot markers ── */
.smap-marker {
cursor: pointer;
transition: transform .15s;
}
.smap-marker:hover { transform: scale(1.15); }
.smap-marker--selected { transform: scale(1.25) !important; }
/* Bounce pour impact haut */
@keyframes bounce {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-5px) scale(1.05); }
}
.smap-marker--high { animation: bounce 1.4s ease-in-out infinite; }
.smap-marker--high.smap-marker--selected { animation: none; transform: scale(1.25); }
/* Pulse ring sur sélection */
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.5); }
100% { box-shadow: 0 0 0 10px rgba(56, 189, 248, 0); }
}
.smap-marker--selected svg circle:first-child {
/* via filter, géré inline */
}

View File

@@ -2,20 +2,78 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/maplibre'; import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { api } from '../../api/client'; import { api } from '../../api/client';
import './SargassesMap.css';
const INITIAL_VIEW = { longitude: -61.55, latitude: 16.25, zoom: 9 }; const INITIAL_VIEW = { longitude: -61.55, latitude: 16.25, zoom: 9 };
const FALLBACK_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
const LEVEL_COLOR = { low: '#22c55e', medium: '#eab308', high: '#ef4444' }; // ── Cartoon style transformer ──────────────────────────────────────────────
function cartoonizeStyle(style) {
const OCEAN = '#3ab5e6', LAND = '#7ec850', LAND_DARK = '#4a7c2f';
const LAND_MID = '#5fb040', BEACH = '#f0e9b0';
const ROAD_MAIN = '#f5c842', ROAD_SEC = '#f0e0a0';
const makeLayerStyles = (isPrediction, opacity) => ({ const layers = style.layers.map(layer => {
const id = (layer.id || '').toLowerCase();
const sl = (layer['source-layer'] || '').toLowerCase();
if (layer.type === 'background') {
return { ...layer, paint: { 'background-color': LAND } };
}
const isWaterFill = layer.type === 'fill' && (
sl === 'water' || id === 'water' || id.startsWith('water-') ||
id.includes('-water') || id.includes('ocean') || id.includes('lake')
);
if (isWaterFill) {
return { ...layer, paint: { 'fill-color': OCEAN, 'fill-antialias': true, 'fill-opacity': 1 } };
}
const isLandFill = layer.type === 'fill' && (
id === 'earth' || id === 'land' || sl === 'earth' || id.includes('landmass')
);
if (isLandFill) return { ...layer, paint: { ...layer.paint, 'fill-color': LAND } };
const isGreenLanduse = layer.type === 'fill' && (
id.includes('park') || id.includes('forest') || id.includes('wood') ||
id.includes('national') || id.includes('grass') || id.includes('vegetation')
);
if (isGreenLanduse) return { ...layer, paint: { ...layer.paint, 'fill-color': LAND_MID, 'fill-opacity': 0.8 } };
if (layer.type === 'fill' && (id.includes('beach') || id.includes('sand'))) {
return { ...layer, paint: { ...layer.paint, 'fill-color': BEACH } };
}
const isCoastLine = layer.type === 'line' && (
sl === 'water' || id.includes('coast') || id.includes('shore') || id.includes('water-')
);
if (isCoastLine) {
return { ...layer, paint: { 'line-color': LAND_DARK, 'line-width': 3 } };
}
if (layer.type === 'line' && (id.includes('motorway') || id.includes('trunk') || id.includes('primary'))) {
return { ...layer, paint: { ...layer.paint, 'line-color': ROAD_MAIN } };
}
if (layer.type === 'line' && (id.includes('secondary') || id.includes('tertiary'))) {
return { ...layer, paint: { ...layer.paint, 'line-color': ROAD_SEC } };
}
return layer;
});
return { ...style, layers };
}
// ── Layer style factories ──────────────────────────────────────────────────
const makeLayerStyles = (isPrediction, opacity, hasPattern) => ({
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' : '#f4a020',
'fill-opacity': (isPrediction ? 0.25 : 0.35) * opacity, 'fill-opacity': (isPrediction ? 0.55 : 0.7) * opacity,
'fill-opacity-transition': { duration: 350, delay: 0 }, 'fill-opacity-transition': { duration: 350 },
}, },
}, },
line: { line: {
@@ -23,15 +81,76 @@ const makeLayerStyles = (isPrediction, opacity) => ({
type: 'line', type: 'line',
source: isPrediction ? 'forecasts' : 'observations', source: isPrediction ? 'forecasts' : 'observations',
paint: { paint: {
'line-color': isPrediction ? '#6366f1' : '#d97706', 'line-color': isPrediction ? '#6366f1' : '#c47a10',
'line-width': 2, 'line-width': isPrediction ? 2 : 3.5,
'line-opacity': opacity, 'line-opacity': opacity,
'line-opacity-transition': { duration: 350, delay: 0 }, 'line-opacity-transition': { duration: 350 },
'line-dasharray': isPrediction ? [4, 3] : [1], 'line-dasharray': isPrediction ? [4, 3] : [1],
}, },
}, },
// Dot texture overlay for observations
...(!isPrediction && hasPattern ? {
dots: {
id: 'observations-dots',
type: 'fill',
source: 'observations',
paint: {
'fill-pattern': 'sargasse-dots',
'fill-opacity': opacity * 0.7,
'fill-opacity-transition': { duration: 350 },
},
},
} : {}),
}); });
// ── SVG Markers ───────────────────────────────────────────────────────────
function SpotMarker({ level, isSelected, name }) {
const size = isSelected ? 36 : 28;
const shadow = 'filter: drop-shadow(2px 3px 0px rgba(0,0,0,0.35))';
if (level === 'high') return (
<div className={`smap-marker smap-marker--high${isSelected ? ' smap-marker--selected' : ''}`}
title={name} style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox="0 0 28 28" style={{ filter: 'drop-shadow(2px 3px 0px rgba(0,0,0,0.35))' }}>
<circle cx="14" cy="14" r="12" fill="#ef4444" stroke="#fff" strokeWidth="2.5"/>
<text x="14" y="20" textAnchor="middle" fontSize="14" fontWeight="bold" fill="white" fontFamily="sans-serif"></text>
</svg>
</div>
);
if (level === 'medium') return (
<div className={`smap-marker smap-marker--medium${isSelected ? ' smap-marker--selected' : ''}`}
title={name} style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox="0 0 28 28" style={{ filter: 'drop-shadow(2px 3px 0px rgba(0,0,0,0.35))' }}>
<circle cx="14" cy="14" r="12" fill="#f59e0b" stroke="#fff" strokeWidth="2.5"/>
<text x="14" y="21" textAnchor="middle" fontSize="17" fontWeight="bold" fill="white" fontFamily="sans-serif">!</text>
</svg>
</div>
);
if (level === 'low') return (
<div className={`smap-marker smap-marker--low${isSelected ? ' smap-marker--selected' : ''}`}
title={name} style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox="0 0 28 28" style={{ filter: 'drop-shadow(2px 3px 0px rgba(0,0,0,0.35))' }}>
<circle cx="14" cy="14" r="12" fill="#22c55e" stroke="#fff" strokeWidth="2.5"/>
<text x="14" y="19" textAnchor="middle" fontSize="14" fill="white" fontFamily="sans-serif"></text>
</svg>
</div>
);
// No score yet
const s = isSelected ? 22 : 14;
return (
<div className={`smap-marker${isSelected ? ' smap-marker--selected' : ''}`}
title={name} style={{ width: s, height: s }}>
<svg width={s} height={s} viewBox="0 0 14 14" style={{ filter: 'drop-shadow(1px 2px 0px rgba(0,0,0,0.3))' }}>
<circle cx="7" cy="7" r="5" fill={isSelected ? '#38bdf8' : '#fff'}
stroke={isSelected ? '#0ea5e9' : '#94a3b8'} strokeWidth="2"/>
</svg>
</div>
);
}
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10); const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) { export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
@@ -42,17 +161,27 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
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); const [layerOpacity, setLayerOpacity] = useState(1);
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
const [patternReady, setPatternReady] = useState(false);
// Spots : chargement unique // Load cartoon map style
useEffect(() => {
fetch('https://tiles.openfreemap.org/styles/bright')
.then(r => r.json())
.then(style => setMapStyle(cartoonizeStyle(style)))
.catch(() => {}); // keep positron on error
}, []);
// Load coastal spots once
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) // Load scores progressively for marker colors
useEffect(() => { useEffect(() => {
if (spots.length === 0) return; if (!spots.length) return;
spots.forEach(s => { spots.forEach(s => {
api.spots.score(s.id) api.spots.score(s.id)
.then(d => { .then(d => {
@@ -63,32 +192,24 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
}); });
}, [spots]); }, [spots]);
// Fade transition quand on change d'étape // Fade transition on step change
useEffect(() => { useEffect(() => {
setLayerOpacity(0); setLayerOpacity(0);
const t = setTimeout(() => setLayerOpacity(1), 60); const t = setTimeout(() => setLayerOpacity(1), 60);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [activeStep]); }, [activeStep]);
// flyTo quand une île est sélectionnée // flyTo on island selection
useEffect(() => { useEffect(() => {
if (!flyToTarget) return; if (!flyToTarget) return;
const map = mapRef.current?.getMap(); mapRef.current?.getMap()?.flyTo({ center: flyToTarget.center, zoom: flyToTarget.zoom, speed: 1.4 });
if (!map) return;
map.flyTo({
center: flyToTarget.center,
zoom: flyToTarget.zoom,
speed: 1.4,
});
}, [flyToTarget]); }, [flyToTarget]);
const loadLayers = useCallback(() => { const loadLayers = useCallback(() => {
const map = mapRef.current?.getMap(); const map = mapRef.current?.getMap();
if (!map) return; if (!map) return;
const b = map.getBounds(); const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()] const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(4)).join(',');
.map(v => v.toFixed(4)).join(',');
if (activeStep === 'now') { if (activeStep === 'now') {
api.observations.list(bbox) api.observations.list(bbox)
@@ -96,12 +217,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
.catch(console.error); .catch(console.error);
setForeGeoJSON(null); setForeGeoJSON(null);
} else { } else {
const horizon = horizonToInt(activeStep); api.forecasts.list(bbox, horizonToInt(activeStep))
api.forecasts.list(bbox, horizon) .then(data => { setForeGeoJSON(toFeatureCollection(data.items)); setObsGeoJSON(null); })
.then(data => {
setForeGeoJSON(toFeatureCollection(data.items));
setObsGeoJSON(null);
})
.catch(console.error); .catch(console.error);
} }
}, [activeStep]); }, [activeStep]);
@@ -109,21 +226,37 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
useEffect(() => { loadLayers(); }, [loadLayers]); useEffect(() => { loadLayers(); }, [loadLayers]);
const toFeatureCollection = (items = []) => { const toFeatureCollection = (items = []) => {
const features = (items ?? []) const features = (items ?? []).filter(i => i.geometry)
.filter(i => i.geometry)
.map(i => ({ type: 'Feature', geometry: i.geometry, properties: {} })); .map(i => ({ type: 'Feature', geometry: i.geometry, properties: {} }));
return features.length ? { type: 'FeatureCollection', features } : null; return features.length ? { type: 'FeatureCollection', features } : null;
}; };
const getCoords = (spot) => { const getCoords = (spot) =>
if (spot.longitude != null && spot.latitude != null) { spot.longitude != null && spot.latitude != null
return { lng: spot.longitude, lat: spot.latitude }; ? { lng: spot.longitude, lat: spot.latitude }
} : null;
return null;
};
const OBS_STYLES = makeLayerStyles(false, layerOpacity); const handleMapLoad = useCallback((e) => {
const FORE_STYLES = makeLayerStyles(true, layerOpacity); const map = e.target;
// Create dot pattern for sargasse texture
const canvas = document.createElement('canvas');
canvas.width = 12; canvas.height = 12;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255, 255, 255, 0.22)';
ctx.beginPath();
ctx.arc(6, 6, 2.5, 0, Math.PI * 2);
ctx.fill();
try {
map.addImage('sargasse-dots', ctx.getImageData(0, 0, 12, 12));
setPatternReady(true);
} catch (_) {}
loadLayers();
}, [loadLayers]);
const OBS_STYLES = makeLayerStyles(false, layerOpacity, patternReady);
const FORE_STYLES = makeLayerStyles(true, layerOpacity, false);
return ( return (
<Map <Map
@@ -131,8 +264,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{...viewState} {...viewState}
onMove={e => setViewState(e.viewState)} onMove={e => setViewState(e.viewState)}
onMoveEnd={loadLayers} onMoveEnd={loadLayers}
onLoad={loadLayers} onLoad={handleMapLoad}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" mapStyle={mapStyle}
style={{ width: '100%', height: '100vh' }} style={{ width: '100%', height: '100vh' }}
> >
<NavigationControl position="bottom-right" /> <NavigationControl position="bottom-right" />
@@ -140,6 +273,7 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{obsGeoJSON && ( {obsGeoJSON && (
<Source id="observations" type="geojson" data={obsGeoJSON}> <Source id="observations" type="geojson" data={obsGeoJSON}>
<Layer {...OBS_STYLES.fill} /> <Layer {...OBS_STYLES.fill} />
{patternReady && <Layer {...OBS_STYLES.dots} />}
<Layer {...OBS_STYLES.line} /> <Layer {...OBS_STYLES.line} />
</Source> </Source>
)} )}
@@ -154,12 +288,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{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 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
@@ -169,19 +299,7 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
anchor="center" anchor="center"
onClick={() => onSpotSelect(spot)} onClick={() => onSpotSelect(spot)}
> >
<div <SpotMarker level={level} isSelected={isSelected} name={spot.name} />
title={spot.name}
style={{
width: size,
height: size,
borderRadius: '50%',
background: color,
border: `2px solid ${isSelected ? '#0ea5e9' : 'rgba(0,0,0,0.3)'}`,
cursor: 'pointer',
transition: 'all .2s',
boxShadow: isSelected ? `0 0 0 3px rgba(14,165,233,.35)` : 'none',
}}
/>
</Marker> </Marker>
); );
})} })}

View File

@@ -4,13 +4,14 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: min(420px, calc(100vw - 32px)); width: min(420px, calc(100vw - 32px));
background: #1e293b; background: #fffbf0;
border: 1px solid #334155; border: 3px solid #333;
border-radius: 16px; border-radius: 20px;
padding: 20px; padding: 20px;
color: #f1f5f9; color: #1a2a1a;
box-shadow: 0 8px 32px rgba(0, 0, 0, .5); box-shadow: 5px 5px 0px #333;
z-index: 10; z-index: 10;
font-family: 'Fredoka', sans-serif;
} }
.spot-panel__close { .spot-panel__close {
@@ -18,52 +19,62 @@
top: 12px; top: 12px;
right: 12px; right: 12px;
background: none; background: none;
border: none; border: 2px solid #333;
color: #94a3b8; border-radius: 8px;
font-size: 16px; color: #333;
font-size: 14px;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 2px 7px;
border-radius: 6px; font-weight: bold;
box-shadow: 2px 2px 0 #333;
transition: transform .1s, box-shadow .1s;
}
.spot-panel__close:hover {
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 #333;
}
.spot-panel__close:active {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 #333;
} }
.spot-panel__close:hover { background: #334155; }
.spot-panel__header { .spot-panel__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 14px;
} }
.spot-panel__icon { font-size: 28px; } .spot-panel__icon { font-size: 28px; }
.spot-panel__name { margin: 0; font-size: 18px; font-weight: 600; } .spot-panel__name { margin: 0; font-size: 19px; font-weight: 600; font-family: 'Fredoka One', cursive; color: #1a1a2e; }
.spot-panel__region { margin: 2px 0 0; font-size: 13px; color: #94a3b8; } .spot-panel__region { margin: 2px 0 0; font-size: 13px; color: #555; }
/* Horizon label */ /* Horizon label */
.spot-panel__horizon-label { .spot-panel__horizon-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #94a3b8; color: #555;
margin-bottom: 10px; margin-bottom: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: .05em; letter-spacing: .05em;
} }
.spot-panel__confidence { font-weight: 400; color: #64748b; } .spot-panel__confidence { font-weight: 400; color: #888; }
/* Score */ /* Score gauge */
.spot-panel__score { margin-bottom: 16px; } .spot-panel__score { margin-bottom: 14px; }
/* Gauge */
.spot-panel__gauge { .spot-panel__gauge {
padding: 14px 16px 12px; padding: 14px 16px 12px;
border-radius: 12px; border-radius: 14px;
margin-bottom: 12px; margin-bottom: 12px;
border: 2px solid rgba(0,0,0,0.15);
} }
.gauge--low { background: rgba(20, 83, 45, 0.45); } .gauge--low { background: #dcfce7; }
.gauge--medium { background: rgba(120, 53, 15, 0.45); } .gauge--medium { background: #fef9c3; }
.gauge--high { background: rgba(127, 29, 29, 0.45); } .gauge--high { background: #fee2e2; }
.spot-panel__gauge-top { .spot-panel__gauge-top {
display: flex; display: flex;
@@ -78,28 +89,31 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: .07em; letter-spacing: .07em;
} }
.gauge--low .spot-panel__gauge-label { color: #86efac; } .gauge--low .spot-panel__gauge-label { color: #16a34a; }
.gauge--medium .spot-panel__gauge-label { color: #fcd34d; } .gauge--medium .spot-panel__gauge-label { color: #d97706; }
.gauge--high .spot-panel__gauge-label { color: #fca5a5; } .gauge--high .spot-panel__gauge-label { color: #dc2626; }
.spot-panel__gauge-score { .spot-panel__gauge-score {
font-size: 30px; font-family: 'Fredoka One', cursive;
font-weight: 700; font-size: 32px;
color: #f1f5f9; font-weight: 400;
color: #1a1a2e;
line-height: 1; line-height: 1;
} }
.spot-panel__gauge-max { .spot-panel__gauge-max {
font-size: 13px; font-size: 13px;
font-weight: 400; font-weight: 400;
color: #64748b; color: #999;
margin-left: 2px; margin-left: 2px;
font-family: 'Fredoka', sans-serif;
} }
.spot-panel__gauge-track { .spot-panel__gauge-track {
height: 5px; height: 6px;
background: rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.1);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(0,0,0,0.1);
} }
.spot-panel__gauge-fill { .spot-panel__gauge-fill {
height: 100%; height: 100%;
@@ -110,6 +124,7 @@
.gauge--medium .spot-panel__gauge-fill { background: #eab308; } .gauge--medium .spot-panel__gauge-fill { background: #eab308; }
.gauge--high .spot-panel__gauge-fill { background: #ef4444; } .gauge--high .spot-panel__gauge-fill { background: #ef4444; }
/* Details */
.spot-panel__details { .spot-panel__details {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -117,21 +132,21 @@
font-size: 13px; font-size: 13px;
margin: 0; margin: 0;
} }
.spot-panel__details dt { color: #94a3b8; } .spot-panel__details dt { color: #777; }
.spot-panel__details dd { margin: 0; font-weight: 500; } .spot-panel__details dd { margin: 0; font-weight: 600; color: #1a1a2e; }
.spot-panel__loading, .spot-panel__loading,
.spot-panel__no-data, .spot-panel__no-data,
.spot-panel__error { color: #94a3b8; font-size: 14px; text-align: center; padding: 12px 0; } .spot-panel__error { font-size: 14px; text-align: center; padding: 12px 0; color: #777; }
.spot-panel__error { color: #f87171; } .spot-panel__error { color: #dc2626; }
/* Feedback */ /* Feedback */
.spot-panel__feedback { .spot-panel__feedback {
border-top: 1px solid #334155; border-top: 2px solid #e5e7eb;
padding-top: 14px; padding-top: 14px;
text-align: center; text-align: center;
} }
.spot-panel__feedback p { margin: 0 0 10px; font-size: 13px; color: #94a3b8; } .spot-panel__feedback p { margin: 0 0 10px; font-size: 13px; color: #555; }
.spot-panel__feedback-btns { .spot-panel__feedback-btns {
display: flex; display: flex;
@@ -141,47 +156,65 @@
.spot-panel__feedback-btns button { .spot-panel__feedback-btns button {
flex: 1; flex: 1;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #475569; border: 2px solid #333;
border-radius: 8px; border-radius: 10px;
background: #0f172a; background: #f1f5f9;
color: #f1f5f9; color: #1a1a2e;
font-family: 'Fredoka', sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background .15s; box-shadow: 2px 2px 0 #333;
transition: transform .1s, box-shadow .1s;
}
.spot-panel__feedback-btns button:hover:not(:disabled) {
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 #333;
}
.spot-panel__feedback-btns button:active:not(:disabled) {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 #333;
} }
.spot-panel__feedback-btns button:hover:not(:disabled) { background: #1e293b; border-color: #64748b; }
.spot-panel__feedback-btns button:disabled { opacity: .5; cursor: default; } .spot-panel__feedback-btns button:disabled { opacity: .5; cursor: default; }
.spot-panel__feedback-thanks { .spot-panel__feedback-thanks {
text-align: center; text-align: center;
color: #86efac; color: #16a34a;
font-size: 14px; font-size: 14px;
font-weight: 600;
margin: 0; margin: 0;
padding-top: 14px; padding-top: 14px;
border-top: 1px solid #334155; border-top: 2px solid #e5e7eb;
} }
/* Push notifications */ /* Push */
.spot-panel__push { .spot-panel__push {
border-top: 1px solid #334155; border-top: 2px solid #e5e7eb;
padding-top: 12px; padding-top: 12px;
text-align: center; text-align: center;
} }
.spot-panel__push-btn { .spot-panel__push-btn {
width: 100%; width: 100%;
padding: 9px 16px; padding: 9px 16px;
border: 1px solid #475569; border: 2px solid #333;
border-radius: 8px; border-radius: 10px;
background: #0f172a; background: #f1f5f9;
color: #94a3b8; color: #555;
font-family: 'Fredoka', sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background .15s, border-color .15s; box-shadow: 2px 2px 0 #333;
transition: transform .1s, box-shadow .1s;
} }
.spot-panel__push-btn:hover:not(:disabled) { background: #1e293b; border-color: #64748b; color: #f1f5f9; } .spot-panel__push-btn:hover:not(:disabled) {
.spot-panel__push-btn--active { border-color: #0ea5e9; color: #38bdf8; } transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 #333;
color: #1a1a2e;
}
.spot-panel__push-btn--active { border-color: #0ea5e9; color: #0284c7; background: #e0f2fe; }
.spot-panel__push-btn:disabled { opacity: .5; cursor: default; } .spot-panel__push-btn:disabled { opacity: .5; cursor: default; }
.spot-panel__push-error { color: #f87171; font-size: 12px; margin-top: 6px; } .spot-panel__push-error { color: #dc2626; font-size: 12px; margin-top: 6px; }
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -191,16 +224,10 @@
right: 0; right: 0;
transform: none; transform: none;
width: 100%; width: 100%;
border-radius: 16px 16px 0 0; border-radius: 20px 20px 0 0;
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
} }
.spot-panel__feedback-btns { flex-direction: column; }
.spot-panel__feedback-btns { .spot-panel__details { grid-template-columns: 1fr; }
flex-direction: column;
}
.spot-panel__details {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,40 +1,46 @@
.timeline { .timeline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 3px;
} }
.timeline__date { .timeline__date {
padding: 3px 10px 3px 0; padding: 3px 10px 3px 0;
color: #64748b; color: #94a3b8;
font-size: 11px; font-family: 'Fredoka', sans-serif;
font-weight: 600; font-size: 12px;
font-weight: 400;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.02em; border-right: 2px solid #333;
border-right: 1px solid #334155;
margin-right: 4px; margin-right: 4px;
} }
.timeline__separator { .timeline__separator { display: none; }
display: none; /* date + border-right fait le même effet */
}
.timeline__step { .timeline__step {
padding: 4px 10px; padding: 4px 11px;
border: none; border: 2px solid transparent;
border-radius: 7px; border-radius: 8px;
background: transparent; background: transparent;
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-family: 'Fredoka', sans-serif;
font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background .15s, color .15s; transition: background .12s, color .12s, border-color .12s, box-shadow .12s, transform .1s;
white-space: nowrap; white-space: nowrap;
} }
.timeline__step:hover { background: #1e293b; color: #f1f5f9; } .timeline__step:hover {
background: #1e3a5f;
color: #f1f5f9;
border-color: #334155;
}
.timeline__step--active { .timeline__step--active {
background: #0ea5e9; background: #0ea5e9;
color: #fff; color: #fff;
border: 2px solid #000;
box-shadow: 2px 2px 0px #000;
font-weight: 600;
} }
@media (max-width: 480px) { @media (max-width: 480px) {

View File

@@ -8,10 +8,9 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 0 16px; padding: 0 16px;
background: rgba(15, 23, 42, 0.88); background: #1a1a2e;
backdrop-filter: blur(14px); border-bottom: 3px solid #000;
-webkit-backdrop-filter: blur(14px); box-shadow: 0 3px 0 #000;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
z-index: 20; z-index: 20;
} }
@@ -23,21 +22,22 @@
flex-shrink: 0; flex-shrink: 0;
} }
.topbar__logo { font-size: 18px; } .topbar__logo { font-size: 20px; }
.topbar__title { .topbar__title {
font-size: 14px; font-family: 'Fredoka One', cursive;
font-weight: 700; font-size: 17px;
font-weight: 400;
color: #f1f5f9; color: #f1f5f9;
letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.02em;
} }
/* ── Islands nav ── */ /* ── Islands nav ── */
.topbar__islands { .topbar__islands {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
overflow-x: auto; overflow-x: auto;
@@ -47,27 +47,30 @@
.topbar__islands::-webkit-scrollbar { display: none; } .topbar__islands::-webkit-scrollbar { display: none; }
.topbar__island-btn { .topbar__island-btn {
padding: 4px 12px; padding: 5px 13px;
border: 1px solid #334155; border: 2px solid #000;
border-radius: 20px; border-radius: 20px;
background: transparent; background: #2563eb;
color: #94a3b8; color: #fff;
font-size: 12px; font-family: 'Fredoka', sans-serif;
font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: background .15s, color .15s, border-color .15s; box-shadow: 3px 3px 0px #000;
transition: transform .1s, box-shadow .1s;
} }
.topbar__island-btn:hover { .topbar__island-btn:hover {
background: #1e293b; transform: translate(-2px, -2px);
color: #f1f5f9; box-shadow: 5px 5px 0px #000;
border-color: #475569; }
.topbar__island-btn:active {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0px #000;
} }
/* ── Timeline slot ── */ /* ── Timeline slot ── */
.topbar__timeline { .topbar__timeline { flex-shrink: 0; }
flex-shrink: 0;
}
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -75,7 +78,6 @@
.topbar { padding: 0 10px; gap: 8px; } .topbar { padding: 0 10px; gap: 8px; }
.topbar__island-btn { padding: 4px 9px; font-size: 11px; } .topbar__island-btn { padding: 4px 9px; font-size: 11px; }
} }
@media (max-width: 420px) { @media (max-width: 420px) {
.topbar__islands { display: none; } .topbar__islands { display: none; }
} }