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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<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-Brx4kn-A.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLFkLo67.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Command;
use App\Entity\CoastalPoint;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-coastal-points',
description: 'Initialise les points côtiers Antilles (idempotent)',
)]
class SeedCoastalPointsCommand extends Command
{
// [nom, lng, lat, type, region]
private const POINTS = [
// Martinique
['Anse Trabaud', -60.8241, 14.4161, 'beach', 'Martinique'],
['Les Salines', -60.8297, 14.4111, 'beach', 'Martinique'],
['Le Diamant', -61.0246, 14.4584, 'beach', 'Martinique'],
['Anse d\'Arlet', -61.0846, 14.4864, 'beach', 'Martinique'],
['Cap Chevalier', -60.8518, 14.4536, 'surf', 'Martinique'],
['Le Marin', -60.8762, 14.4726, 'port', 'Martinique'],
['Fort-de-France', -61.0736, 14.6048, 'port', 'Martinique'],
['Grand Rivière', -61.1897, 14.8737, 'fishing', 'Martinique'],
['Le Vauclin', -60.8380, 14.5548, 'fishing', 'Martinique'],
['Sainte-Anne', -60.8833, 14.4300, 'beach', 'Martinique'],
// Guadeloupe
['Saint-François', -61.2695, 16.2527, 'beach', 'Guadeloupe'],
['Sainte-Anne (Gwad)', -61.3789, 16.2248, 'beach', 'Guadeloupe'],
['Le Gosier', -61.4918, 16.1989, 'beach', 'Guadeloupe'],
['Plage de la Caravelle',-61.3661, 16.2361, 'surf', 'Guadeloupe'],
['Pointe-à-Pitre', -61.5361, 16.2419, 'port', 'Guadeloupe'],
['Basse-Terre port', -61.7339, 15.9955, 'port', 'Guadeloupe'],
['Bouillante', -61.7681, 16.1348, 'beach', 'Guadeloupe'],
['Capesterre-Belle-Eau',-61.5621, 16.0432, 'fishing', 'Guadeloupe'],
// Sainte-Lucie
['Rodney Bay', -60.9520, 14.0778, 'port', 'Sainte-Lucie'],
['Anse Chastanet', -61.0661, 13.8588, 'beach', 'Sainte-Lucie'],
['Marigot Bay', -61.0230, 13.9667, 'port', 'Sainte-Lucie'],
['Castries harbour', -61.0045, 14.0101, 'port', 'Sainte-Lucie'],
// Barbade
['Miami Beach Barbados', -59.6203, 13.0756, 'surf', 'Barbade'],
['Bathsheba', -59.5337, 13.2115, 'surf', 'Barbade'],
['Bridgetown Harbour', -59.6147, 13.0975, 'port', 'Barbade'],
['Carlisle Bay', -59.6161, 13.0817, 'beach', 'Barbade'],
// Saint-Martin
['Orient Bay', -63.0311, 18.0917, 'beach', 'Saint-Martin'],
['Grand Case', -63.0500, 18.1017, 'beach', 'Saint-Martin'],
['Marigot', -63.0833, 18.0667, 'port', 'Saint-Martin'],
];
public function __construct(private EntityManagerInterface $em)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Seed CoastalPoints — Antilles');
$repo = $this->em->getRepository(CoastalPoint::class);
$created = 0;
$skipped = 0;
foreach (self::POINTS as [$name, $lng, $lat, $type, $region]) {
$existing = $repo->findOneBy(['name' => $name, 'region' => $region]);
if ($existing !== null) {
$skipped++;
continue;
}
$point = new CoastalPoint();
$point->setName($name);
$point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lng, $lat));
$point->setType($type);
$point->setRegion($region);
$this->em->persist($point);
$created++;
}
$this->em->flush();
$io->success(sprintf('%d point(s) créé(s), %d ignoré(s) (déjà existants).', $created, $skipped));
return Command::SUCCESS;
}
}

View File

@@ -52,7 +52,7 @@
- [x] Rasterisation → raster UINT8 binaire (evalscript cloud-side)
- [x] Vectorisation GDAL (`gdal_polygonize.py`) + simplification PostGIS
- [x] Persistance `SargassumObservation`
- [ ] Génération vector tiles (Tippecanoe → `.pbf`)
- [ ] Génération vector tiles (Tippecanoe → `.pbf`) — différé Phase 2
### API Backend
@@ -66,14 +66,16 @@
### Frontend React
- [ ] Carte de base (MapLibre GL JS)
- [ ] Affichage vector tiles observations
- [ ] Sélection d'un CoastalPoint (Spot Mode)
- [ ] Affichage polygones observés
- [x] Carte de base (MapLibre GL JS + fond CARTO dark)
- [x] Affichage observations GeoJSON (bbox dynamique au mouvement)
- [x] Marqueurs CoastalPoints avec sélection
- [x] SpotPanel : Spot Mode complet (score, niveau, distance, trend)
- [x] Bouton feedback terrain (confirmation/infirmation anonyme)
- [x] Build Vite → backend/public/spa/ (SPA statique servie par Caddy)
### Données initiales
- [ ] Seed `CoastalPoint` zone Antilles (plages, ports, spots surf, zones pêche)
- [x] Seed `CoastalPoint` zone Antilles — 29 points (plages, ports, surf, pêche)
---

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>
);
}

View File

@@ -1,7 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
server: {
proxy: {
// En dev local, proxy /api → Symfony (port 8000)
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: '../backend/public/spa',
emptyOutDir: true,
},
});