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" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-BGNw_mST.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9sgBDR3.css">
<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">
<script type="module" crossorigin src="/assets/index-B_-zKuKT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-RGvCvNm_.css">
</head>
<body>
<div id="root"></div>

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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>
<body>
<div id="root"></div>

View File

@@ -1,9 +1,9 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Fredoka', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #f1f5f9;
color: #1a2a1a;
overflow: hidden;
}
@@ -12,3 +12,15 @@ body {
width: 100vw;
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}
/>
<div className="app__wave-overlay" aria-hidden="true" />
<SargassesMap
onSpotSelect={setSelectedSpot}
selectedSpotId={selectedSpot?.id}

View File

@@ -1,54 +1,66 @@
.legend {
position: absolute;
bottom: 24px;
bottom: 48px;
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;
background: #fffbf0;
border: 3px solid #333;
border-radius: 16px;
padding: 10px 14px 12px;
display: flex;
flex-direction: column;
gap: 6px;
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 {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: #94a3b8;
font-family: 'Fredoka', sans-serif;
font-size: 12px;
font-weight: 500;
color: #333;
white-space: nowrap;
}
.legend__swatch {
width: 14px;
width: 16px;
height: 10px;
border-radius: 2px;
border-radius: 3px;
border: 2px solid #333;
flex-shrink: 0;
}
.legend__swatch--obs { background: #f59e0b; opacity: 0.8; }
.legend__swatch--fore { background: #818cf8; opacity: 0.8; }
.legend__swatch--obs { background: #f4a020; }
.legend__swatch--fore { background: #818cf8; }
.legend__divider {
height: 1px;
background: #334155;
height: 2px;
background: #333;
margin: 2px 0;
border-radius: 1px;
}
.legend__dot {
width: 9px;
height: 9px;
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid #fff;
outline: 2px solid #333;
flex-shrink: 0;
}
.legend__dot--low { background: #22c55e; }
.legend__dot--medium { background: #eab308; }
.legend__dot--medium { background: #f59e0b; }
.legend__dot--high { background: #ef4444; }
/* Mobile: déplacer si spotPanel est visible */
@media (max-width: 480px) {
.legend { bottom: auto; top: 60px; left: 10px; }
}

View File

@@ -3,6 +3,7 @@ import './Legend.css';
export default function Legend() {
return (
<aside className="legend" aria-label="Légende">
<p className="legend__title">Légende</p>
<div className="legend__item">
<span className="legend__swatch legend__swatch--obs" />
<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,57 +2,186 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { api } from '../../api/client';
import './SargassesMap.css';
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: {
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
type: 'fill',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'fill-color': isPrediction ? '#818cf8' : '#f59e0b',
'fill-opacity': (isPrediction ? 0.25 : 0.35) * opacity,
'fill-opacity-transition': { duration: 350, delay: 0 },
paint: {
'fill-color': isPrediction ? '#818cf8' : '#f4a020',
'fill-opacity': (isPrediction ? 0.55 : 0.7) * opacity,
'fill-opacity-transition': { duration: 350 },
},
},
line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
type: 'line',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'line-color': isPrediction ? '#6366f1' : '#d97706',
'line-width': 2,
'line-opacity': opacity,
'line-opacity-transition': { duration: 350, delay: 0 },
'line-dasharray': isPrediction ? [4, 3] : [1],
paint: {
'line-color': isPrediction ? '#6366f1' : '#c47a10',
'line-width': isPrediction ? 2 : 3.5,
'line-opacity': opacity,
'line-opacity-transition': { duration: 350 },
'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);
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
const mapRef = useRef(null);
const [spots, setSpots] = useState([]);
const [spotScores, setSpotScores] = useState({});
const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [foreGeoJSON, setForeGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [spots, setSpots] = useState([]);
const [spotScores, setSpotScores] = useState({});
const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [foreGeoJSON, setForeGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [layerOpacity, setLayerOpacity] = useState(1);
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(() => {
api.spots.list()
.then(data => setSpots(Array.isArray(data) ? data : (data['hydra:member'] ?? data.member ?? [])))
.catch(console.error);
}, []);
// Scores des spots pour colorier les marqueurs (chargement progressif)
// Load scores progressively for marker colors
useEffect(() => {
if (spots.length === 0) return;
if (!spots.length) return;
spots.forEach(s => {
api.spots.score(s.id)
.then(d => {
@@ -63,32 +192,24 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
});
}, [spots]);
// Fade transition quand on change d'étape
// Fade transition on step change
useEffect(() => {
setLayerOpacity(0);
const t = setTimeout(() => setLayerOpacity(1), 60);
return () => clearTimeout(t);
}, [activeStep]);
// flyTo quand une île est sélectionnée
// flyTo on island selection
useEffect(() => {
if (!flyToTarget) return;
const map = mapRef.current?.getMap();
if (!map) return;
map.flyTo({
center: flyToTarget.center,
zoom: flyToTarget.zoom,
speed: 1.4,
});
mapRef.current?.getMap()?.flyTo({ center: flyToTarget.center, zoom: flyToTarget.zoom, speed: 1.4 });
}, [flyToTarget]);
const loadLayers = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
.map(v => v.toFixed(4)).join(',');
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(4)).join(',');
if (activeStep === 'now') {
api.observations.list(bbox)
@@ -96,12 +217,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
.catch(console.error);
setForeGeoJSON(null);
} else {
const horizon = horizonToInt(activeStep);
api.forecasts.list(bbox, horizon)
.then(data => {
setForeGeoJSON(toFeatureCollection(data.items));
setObsGeoJSON(null);
})
api.forecasts.list(bbox, horizonToInt(activeStep))
.then(data => { setForeGeoJSON(toFeatureCollection(data.items)); setObsGeoJSON(null); })
.catch(console.error);
}
}, [activeStep]);
@@ -109,21 +226,37 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
useEffect(() => { loadLayers(); }, [loadLayers]);
const toFeatureCollection = (items = []) => {
const features = (items ?? [])
.filter(i => i.geometry)
const features = (items ?? []).filter(i => i.geometry)
.map(i => ({ type: 'Feature', geometry: i.geometry, properties: {} }));
return features.length ? { type: 'FeatureCollection', features } : null;
};
const getCoords = (spot) => {
if (spot.longitude != null && spot.latitude != null) {
return { lng: spot.longitude, lat: spot.latitude };
}
return null;
};
const getCoords = (spot) =>
spot.longitude != null && spot.latitude != null
? { lng: spot.longitude, lat: spot.latitude }
: null;
const OBS_STYLES = makeLayerStyles(false, layerOpacity);
const FORE_STYLES = makeLayerStyles(true, layerOpacity);
const handleMapLoad = useCallback((e) => {
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 (
<Map
@@ -131,8 +264,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{...viewState}
onMove={e => setViewState(e.viewState)}
onMoveEnd={loadLayers}
onLoad={loadLayers}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
onLoad={handleMapLoad}
mapStyle={mapStyle}
style={{ width: '100%', height: '100vh' }}
>
<NavigationControl position="bottom-right" />
@@ -140,6 +273,7 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{obsGeoJSON && (
<Source id="observations" type="geojson" data={obsGeoJSON}>
<Layer {...OBS_STYLES.fill} />
{patternReady && <Layer {...OBS_STYLES.dots} />}
<Layer {...OBS_STYLES.line} />
</Source>
)}
@@ -154,12 +288,8 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
const isSelected = spot.id === selectedSpotId;
const level = spotScores[spot.id];
const baseColor = level ? LEVEL_COLOR[level] : '#cbd5e1';
const color = isSelected ? '#38bdf8' : baseColor;
const size = isSelected ? 16 : 10;
return (
<Marker
@@ -169,19 +299,7 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
anchor="center"
onClick={() => onSpotSelect(spot)}
>
<div
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',
}}
/>
<SpotMarker level={level} isSelected={isSelected} name={spot.name} />
</Marker>
);
})}

View File

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

View File

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

View File

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