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:
9
backend/public/assets/index-B_-zKuKT.js
Normal file
9
backend/public/assets/index-B_-zKuKT.js
Normal file
File diff suppressed because one or more lines are too long
1
backend/public/assets/index-RGvCvNm_.css
Normal file
1
backend/public/assets/index-RGvCvNm_.css
Normal file
File diff suppressed because one or more lines are too long
756
backend/public/assets/maplibre-gl-D0JYst_z.js
Normal file
756
backend/public/assets/maplibre-gl-D0JYst_z.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
24
frontend/src/components/Map/SargassesMap.css
Normal file
24
frontend/src/components/Map/SargassesMap.css
Normal 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 */
|
||||||
|
}
|
||||||
@@ -2,57 +2,186 @@ 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: {
|
||||||
id: isPrediction ? 'forecasts-line' : 'observations-line',
|
id: isPrediction ? 'forecasts-line' : 'observations-line',
|
||||||
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 }) {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const [spots, setSpots] = useState([]);
|
const [spots, setSpots] = useState([]);
|
||||||
const [spotScores, setSpotScores] = 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);
|
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user