Phase 1 : seed CoastalPoints + frontend React complet

Backend :
- SeedCoastalPointsCommand : 29 points Antilles (idempotent)
  Martinique, Guadeloupe, Sainte-Lucie, Barbade, Saint-Martin

Frontend :
- api/client.js : wrapper fetch pour spots, observations, feedback
- SargassesMap : MapLibre GL JS, marqueurs spots, couche observations
  GeoJSON avec rechargement bbox dynamique à chaque mouvement carte
- SpotPanel : Spot Mode (score/niveau/distance/trend), bouton feedback
  terrain anonyme, gestion état loading/error/null
- App.jsx : composition map + panel, état selectedSpot
- vite.config.js : proxy /api → Symfony dev, build → backend/public/spa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:17:43 -04:00
parent 4af5e62465
commit 0c1deb93cb
15 changed files with 1369 additions and 306 deletions

View File

@@ -1,184 +1,14 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #f1f5f9;
overflow: hidden;
}
.hero {
.app {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
width: 100vw;
height: 100vh;
}

View File

@@ -1,121 +1,21 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { useState } from 'react';
import SargassesMap from './components/Map/SargassesMap';
import SpotPanel from './components/SpotPanel/SpotPanel';
import './App.css';
function App() {
const [count, setCount] = useState(0)
export default function App() {
const [selectedSpot, setSelectedSpot] = useState(null);
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
<div className="app">
<SargassesMap
onSpotSelect={setSelectedSpot}
selectedSpotId={selectedSpot?.id}
/>
<SpotPanel
spot={selectedSpot}
onClose={() => setSelectedSpot(null)}
/>
</div>
);
}
export default App

View File

@@ -0,0 +1,39 @@
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api';
async function get(path, params = {}) {
const url = new URL(BASE_URL + path, window.location.origin);
Object.entries(params).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v));
const res = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`);
return res.json();
}
async function post(path, body) {
const res = await fetch(BASE_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`);
return res.json();
}
export const api = {
spots: {
list: () => get('/spots'),
get: (id) => get(`/spots/${id}`),
score: (id, at) => get(`/spots/${id}/score`, { at }),
},
observations: {
list: (bbox, date, source) => get('/observations', { bbox, date, source }),
get: (id) => get(`/observations/${id}`),
},
feedback: {
create: (data) => post('/feedback', data),
},
};

View File

@@ -0,0 +1,153 @@
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';
// Emprise initiale : Antilles
const INITIAL_VIEW = {
longitude: -61.2,
latitude: 15.5,
zoom: 7,
};
const LEVEL_COLOR = {
low: '#22c55e', // vert
medium: '#f59e0b', // orange
high: '#ef4444', // rouge
};
// Styles des couches MapLibre pour les observations
const OBSERVATION_FILL = {
id: 'observations-fill',
type: 'fill',
source: 'observations',
paint: {
'fill-color': '#f59e0b',
'fill-opacity': 0.35,
},
};
const OBSERVATION_LINE = {
id: 'observations-line',
type: 'line',
source: 'observations',
paint: {
'line-color': '#d97706',
'line-width': 2,
},
};
export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
const mapRef = useRef(null);
const [spots, setSpots] = useState([]);
const [observationGeoJSON, setObs] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
// Charge les spots côtiers une seule fois
useEffect(() => {
api.spots.list()
.then(data => setSpots(data['hydra:member'] ?? data.member ?? []))
.catch(console.error);
}, []);
// Charge les observations quand la carte bouge (debounced)
const loadObservations = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const bounds = map.getBounds();
const bbox = [
bounds.getWest().toFixed(4),
bounds.getSouth().toFixed(4),
bounds.getEast().toFixed(4),
bounds.getNorth().toFixed(4),
].join(',');
api.observations.list(bbox)
.then(data => {
if (!data.items?.length) { setObs(null); return; }
// Regroupe toutes les observations en une FeatureCollection
const features = data.items
.filter(o => o.geometry)
.map(o => ({
type: 'Feature',
geometry: o.geometry,
properties: {
id: o.id,
detectedAt: o.detectedAt,
cloudCoverage: o.cloudCoverage,
coverageArea: o.coverageArea,
},
}));
setObs({ type: 'FeatureCollection', features });
})
.catch(console.error);
}, []);
// Recharge les observations après chaque fin de mouvement
const onMoveEnd = useCallback(() => {
loadObservations();
}, [loadObservations]);
// Extraction des coordonnées depuis la géométrie PostGIS (Point GeoJSON)
const getCoords = (spot) => {
const coords = spot.geometry?.coordinates;
if (!coords) return null;
return { lng: coords[0], lat: coords[1] };
};
return (
<Map
ref={mapRef}
{...viewState}
onMove={e => setViewState(e.viewState)}
onMoveEnd={onMoveEnd}
onLoad={loadObservations}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
style={{ width: '100%', height: '100vh' }}
>
<NavigationControl position="top-right" />
{/* Couche observations sargasses */}
{observationGeoJSON && (
<Source id="observations" type="geojson" data={observationGeoJSON}>
<Layer {...OBSERVATION_FILL} />
<Layer {...OBSERVATION_LINE} />
</Source>
)}
{/* Marqueurs spots côtiers */}
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
const isSelected = spot.id === selectedSpotId;
return (
<Marker
key={spot.id}
longitude={coords.lng}
latitude={coords.lat}
anchor="center"
onClick={() => onSpotSelect(spot)}
>
<div
title={spot.name}
style={{
width: isSelected ? 16 : 10,
height: isSelected ? 16 : 10,
borderRadius: '50%',
background: isSelected ? '#38bdf8' : '#fff',
border: `2px solid ${isSelected ? '#0ea5e9' : '#94a3b8'}`,
cursor: 'pointer',
transition: 'all .15s',
}}
/>
</Marker>
);
})}
</Map>
);
}

View File

@@ -0,0 +1,109 @@
.spot-panel {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: min(420px, calc(100vw - 32px));
background: #1e293b;
border: 1px solid #334155;
border-radius: 16px;
padding: 20px;
color: #f1f5f9;
box-shadow: 0 8px 32px rgba(0, 0, 0, .5);
z-index: 10;
}
.spot-panel__close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
color: #94a3b8;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.spot-panel__close:hover { background: #334155; }
.spot-panel__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.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; }
/* Score */
.spot-panel__score { margin-bottom: 16px; }
.spot-panel__level {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 12px;
font-weight: 600;
}
.level--ok { background: #14532d; color: #86efac; }
.level--risk { background: #78350f; color: #fcd34d; }
.level--impact { background: #7f1d1d; color: #fca5a5; }
.spot-panel__level-label { font-size: 18px; }
.spot-panel__level-value { font-size: 14px; opacity: .8; }
.spot-panel__details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: 13px;
margin: 0;
}
.spot-panel__details dt { color: #94a3b8; }
.spot-panel__details dd { margin: 0; font-weight: 500; }
.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; }
/* Feedback */
.spot-panel__feedback {
border-top: 1px solid #334155;
padding-top: 14px;
text-align: center;
}
.spot-panel__feedback p { margin: 0 0 10px; font-size: 13px; color: #94a3b8; }
.spot-panel__feedback-btns {
display: flex;
gap: 8px;
justify-content: center;
}
.spot-panel__feedback-btns button {
flex: 1;
padding: 8px 12px;
border: 1px solid #475569;
border-radius: 8px;
background: #0f172a;
color: #f1f5f9;
font-size: 13px;
cursor: pointer;
transition: background .15s;
}
.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;
font-size: 14px;
margin: 0;
padding-top: 14px;
border-top: 1px solid #334155;
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
export default function SpotPanel({ spot, onClose }) {
const [score, setScore] = useState(undefined); // undefined = loading
const [error, setError] = useState(null);
useEffect(() => {
if (!spot) return;
setScore(undefined);
setError(null);
api.spots.score(spot.id)
.then(data => setScore(data))
.catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]);
if (!spot) return null;
const icon = TYPE_ICON[spot.type] ?? '📍';
const lvl = score?.score?.level;
return (
<div className="spot-panel">
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button>
<div className="spot-panel__header">
<span className="spot-panel__icon">{icon}</span>
<div>
<h2 className="spot-panel__name">{spot.name}</h2>
<p className="spot-panel__region">{spot.region}</p>
</div>
</div>
<div className="spot-panel__score">
{score === undefined && <p className="spot-panel__loading">Chargement</p>}
{error && <p className="spot-panel__error">{error}</p>}
{score !== undefined && !error && (
score.score === null
? <p className="spot-panel__no-data">Aucune donnée disponible pour ce spot.</p>
: (
<>
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span>
<span className="spot-panel__level-value">{score.score.value} / 100</span>
</div>
<dl className="spot-panel__details">
{score.score.distance != null && (
<>
<dt>Distance sargasses</dt>
<dd>{score.score.distance.toFixed(1)} km</dd>
</>
)}
{score.score.trend && (
<>
<dt>Tendance</dt>
<dd>{score.score.trend}</dd>
</>
)}
{score.score.computedAt && (
<>
<dt>Calculé le</dt>
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
</>
)}
</dl>
</>
)
)}
</div>
<FeedbackButton spot={spot} />
</div>
);
}
function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const submit = async (hasSeaweed) => {
if (loading || sent) return;
setLoading(true);
const coords = spot.geometry?.coordinates;
if (!coords) return;
await api.feedback.create({
lat: coords[1],
lng: coords[0],
hasSeaweed,
coastalPointId: spot.id,
}).catch(console.error);
setLoading(false);
setSent(true);
};
if (sent) return <p className="spot-panel__feedback-thanks">Merci pour votre retour !</p>;
return (
<div className="spot-panel__feedback">
<p>Vous êtes sur place ?</p>
<div className="spot-panel__feedback-btns">
<button onClick={() => submit(true)} disabled={loading}> Je vois des sargasses</button>
<button onClick={() => submit(false)} disabled={loading}> Pas de sargasses</button>
</div>
</div>
);
}