Phase 2 : dérive, ImpactScore, slider temporel

Backend :
- OpenMeteoClient : vent horaire 72h (gratuit, sans clé)
- DriftSimulationService : échantillonnage ST_GeneratePoints,
  déplacement itératif Stokes 3%, reconstruction ST_ConcaveHull,
  4 horizons H+6/12/24/48, confiance décroissante
- ImpactScoreService : score 4 composantes (distance/densité/
  vitesse/tendance), cache Redis TTL 3h, invalidation à chaque calcul
- ComputeForecastsCommand : traite les observations sans forecast
- SpotScoreController : score courant + horizons depuis forecasts

Frontend :
- TimelineSlider : navigation Now/+6h/+12h/+24h/+48h
- SargassesMap : couche observations (orange) vs forecasts (violet)
  rechargée à chaque changement d'étape
- SpotPanel : affichage score par horizon actif + indicateur confiance
- Build → backend/public/spa/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:23:21 -04:00
parent 0c1deb93cb
commit 94bb6f5e8c
18 changed files with 983 additions and 186 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,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">
<script type="module" crossorigin src="/assets/index-BV2EXFwR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jpyCrywh.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Command;
use App\Entity\SargassumObservation;
use App\Service\Forecast\DriftSimulationService;
use App\Service\Forecast\ImpactScoreService;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:compute-forecasts',
description: 'Calcule les forecasts de dérive et les ImpactScores pour les dernières observations',
)]
class ComputeForecastsCommand extends Command
{
public function __construct(
private DriftSimulationService $drift,
private ImpactScoreService $scores,
private EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('limit', 'l', InputOption::VALUE_OPTIONAL,
'Nombre max d\'observations à traiter', 5);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$limit = (int) $input->getOption('limit');
$io->title('Calcul forecasts + ImpactScores');
// Observations récentes sans forecast
$observations = $this->em->getRepository(SargassumObservation::class)
->createQueryBuilder('o')
->leftJoin('o.forecasts', 'f')
->where('f.id IS NULL')
->orderBy('o.detectedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
if (empty($observations)) {
$io->info('Aucune observation sans forecast trouvée.');
return Command::SUCCESS;
}
$io->progressStart(count($observations));
$errors = 0;
foreach ($observations as $observation) {
try {
$forecasts = $this->drift->computeForecasts($observation);
$io->text(sprintf(
'Observation %s → %d forecast(s)',
$observation->getId(),
count($forecasts)
));
$this->scores->computeForObservation($observation, $forecasts);
} catch (\Throwable $e) {
$io->error('Erreur sur ' . $observation->getId() . ' : ' . $e->getMessage());
$errors++;
}
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf(
'%d observation(s) traitée(s), %d erreur(s).',
count($observations) - $errors,
$errors
));
return $errors === 0 ? Command::SUCCESS : Command::FAILURE;
}
}

View File

@@ -3,7 +3,8 @@
namespace App\Controller;
use App\Entity\CoastalPoint;
use App\Entity\ImpactScore;
use App\Service\Forecast\ImpactScoreService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -13,55 +14,135 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/spots', name: 'api_spots_')]
class SpotScoreController extends AbstractController
{
public function __construct(private EntityManagerInterface $em) {}
public function __construct(
private EntityManagerInterface $em,
private ImpactScoreService $scoreService,
private Connection $connection,
) {}
/**
* GET /api/spots/{id}/score
*
* Retourne le score courant pour un CoastalPoint.
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ (score à un instant donné)
* Retourne le score courant + les scores par horizon (H+6/12/24/48).
* Utilise le cache Redis (TTL 3h).
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ
*/
#[Route('/{id}/score', name: 'score', methods: ['GET'])]
public function score(string $id, Request $request): JsonResponse
{
$spot = $this->em->getRepository(CoastalPoint::class)->find($id);
if ($spot === null) {
return $this->json(['error' => 'Spot not found'], 404);
}
$qb = $this->em->getRepository(ImpactScore::class)
->createQueryBuilder('s')
->where('s.coastalPoint = :spot')
->setParameter('spot', $spot)
->orderBy('s.timestamp', 'DESC')
->setMaxResults(1);
$atParam = $request->query->get('at');
// Si ?at= spécifié, on ne peut pas utiliser le cache
if ($atParam !== null) {
try {
$at = new \DateTimeImmutable($atParam);
$qb->andWhere('s.timestamp <= :at')->setParameter('at', $at);
} catch (\Exception) {
return $this->json(['error' => 'Invalid "at" parameter format'], 400);
}
$current = $this->loadScoreAt($id, $at);
} else {
$current = $this->scoreService->getCachedScore($id);
}
$latest = $qb->getQuery()->getOneOrNullResult();
return $this->json([
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $latest ? [
'value' => $latest->getScore(),
'level' => $latest->getLevel(),
'distance' => $latest->getDistanceToNearestSargassum(),
'density' => $latest->getDensityEstimate(),
'trend' => $latest->getTrend(),
'computedAt' => $latest->getTimestamp()?->format(\DateTimeInterface::ATOM),
] : null,
'spotId' => (string) $spot->getId(),
'name' => $spot->getName(),
'type' => $spot->getType(),
'region' => $spot->getRegion(),
'score' => $current,
'horizons' => $this->loadHorizonScores($id),
]);
}
// -------------------------------------------------------------------------
/**
* Score à un instant précis (sans cache).
*/
private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT score, level, distance_to_nearest_sargassum AS distance,
density_estimate AS density, trend, timestamp AS computed_at
FROM impact_score
WHERE coastal_point_id = :spotId AND timestamp <= :at
ORDER BY timestamp DESC LIMIT 1',
['spotId' => $spotId, 'at' => $at->format('Y-m-d H:i:s')]
);
return $row !== false ? $this->formatScore($row) : null;
}
/**
* Scores estimés par horizon à partir des forecasts les plus récents.
* Retourne null si aucun forecast disponible.
*/
private function loadHorizonScores(string $spotId): ?array
{
// Dernière observation avec forecasts
$rows = $this->connection->fetchAllAssociative(
'SELECT
f.time_horizon AS horizon,
ROUND(CAST(ST_Distance(
cp.geometry::geography,
f.geometry::geography
) / 1000 AS numeric), 2) AS distance_km,
f.confidence,
f.valid_at
FROM sargassum_forecast f
JOIN coastal_point cp ON cp.id = :spotId
WHERE f.source_observation_id = (
SELECT o.id FROM sargassum_observation o
INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o.id
ORDER BY o.detected_at DESC LIMIT 1
)
ORDER BY f.time_horizon ASC',
['spotId' => $spotId]
);
if (empty($rows)) {
return null;
}
$horizons = [];
foreach ($rows as $row) {
$distKm = (float) $row['distance_km'];
$score = max(0, (int) round((1 - min($distKm / 50, 1)) * 100));
$horizons['h' . $row['horizon']] = [
'score' => $score,
'level' => $this->scoreToLevel($score),
'distanceKm' => $distKm,
'confidence' => (float) $row['confidence'],
'validAt' => $row['valid_at'],
];
}
return $horizons;
}
private function formatScore(array $row): array
{
return [
'value' => (int) $row['score'],
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'computedAt' => $row['computed_at'],
];
}
private function scoreToLevel(int $score): string
{
return match (true) {
$score <= 30 => 'low',
$score <= 70 => 'medium',
default => 'high',
};
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Service\Forecast;
use App\Entity\SargassumForecast;
use App\Entity\SargassumObservation;
use App\Service\Weather\OpenMeteoClient;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Simule la dérive des sargasses à partir d'une observation.
*
* Algorithme :
* 1. Échantillonnage PostGIS (ST_GeneratePoints) → N points internes
* 2. Pour chaque point, déplacement horaire via vecteur vent Open-Meteo
* (modèle Stokes : 3% vitesse vent = contribution sargassum)
* 3. Reconstruction du polygone par ST_ConcaveHull des points déplacés
* 4. Sauvegarde d'un SargassumForecast par horizon (H+6/12/24/48)
*/
class DriftSimulationService
{
private const HORIZONS = [6, 12, 24, 48];
private const SAMPLE_POINTS = 30;
private const WIND_FACTOR = 0.03; // 3% du vent = dérive Stokes
private const MODEL_VERSION = '1.0.0';
private const CONCAVITY = 0.8; // paramètre ST_ConcaveHull (0=convex, 1=très concave)
public function __construct(
private OpenMeteoClient $weather,
private Connection $connection,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
/**
* Calcule et persiste les forecasts pour une observation.
*
* @return SargassumForecast[]
*/
public function computeForecasts(SargassumObservation $observation): array
{
$id = (string) $observation->getId();
// Centroïde de l'observation → point de référence pour Open-Meteo
$centroid = $this->getCentroid($id);
if ($centroid === null) {
$this->logger->warning('Centroid unavailable, skipping forecast', ['id' => $id]);
return [];
}
// Vents horaires sur 3 jours (72h)
$windData = $this->weather->getHourlyWind($centroid['lat'], $centroid['lng']);
// Points échantillonnés à l'intérieur du polygone
$samplePoints = $this->samplePoints($id);
if (empty($samplePoints)) {
$this->logger->warning('No sample points, skipping forecast', ['id' => $id]);
return [];
}
$forecasts = [];
$currentPoints = $samplePoints; // état courant des points
$prevHorizon = 0;
foreach (self::HORIZONS as $horizon) {
// Déplace les points heure par heure de $prevHorizon à $horizon
$currentPoints = $this->displacePoints($currentPoints, $windData, $prevHorizon, $horizon);
// Reconstruit le polygone depuis les points déplacés
$geometry = $this->reconstructPolygon($currentPoints);
if ($geometry === null) {
$this->logger->info("No polygon for H+{$horizon}", ['id' => $id]);
$prevHorizon = $horizon;
continue;
}
// Vecteur de dérive moyen sur cette période
$driftVector = $this->avgDriftVector($windData, $prevHorizon, $horizon);
$forecast = new SargassumForecast();
$forecast->setSourceObservation($observation);
$forecast->setGeometry($geometry);
$forecast->setValidAt($observation->getDetectedAt()->modify("+{$horizon} hours"));
$forecast->setModelVersion(self::MODEL_VERSION);
$forecast->setTimeHorizon($horizon);
$forecast->setConfidence($this->computeConfidence($horizon));
$forecast->setDriftVectorAvg(
sprintf('SRID=4326;POINT(%f %f)', $driftVector['lng'], $driftVector['lat'])
);
$this->em->persist($forecast);
$forecasts[] = $forecast;
$prevHorizon = $horizon;
}
$this->em->flush();
return $forecasts;
}
// -------------------------------------------------------------------------
private function getCentroid(string $observationId): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT ST_X(ST_Centroid(geometry)) AS lng,
ST_Y(ST_Centroid(geometry)) AS lat
FROM sargassum_observation WHERE id = :id',
['id' => $observationId]
);
return ($row !== false) ? ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']] : null;
}
/**
* Génère N points aléatoires à l'intérieur du polygone via PostGIS.
*
* @return array<int, array{lat: float, lng: float}>
*/
private function samplePoints(string $observationId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT ST_X(geom) AS lng, ST_Y(geom) AS lat
FROM (
SELECT (ST_Dump(ST_GeneratePoints(geometry, :n))).geom
FROM sargassum_observation WHERE id = :id
) AS pts',
['n' => self::SAMPLE_POINTS, 'id' => $observationId]
);
return array_map(fn($r) => ['lat' => (float) $r['lat'], 'lng' => (float) $r['lng']], $rows);
}
/**
* Déplace un ensemble de points sur la période [$fromHour..$toHour]
* en appliquant le vecteur vent heure par heure.
*
* @param array<int, array{lat: float, lng: float}> $points
* @param array<int, array{speed: float, direction: float}> $windData
* @return array<int, array{lat: float, lng: float}>
*/
private function displacePoints(array $points, array $windData, int $fromHour, int $toHour): array
{
for ($h = $fromHour; $h < $toHour; $h++) {
$wind = $windData[$h] ?? ['speed' => 0, 'direction' => 0];
$speed = $wind['speed'] * self::WIND_FACTOR; // m/s
$dirRad = deg2rad($wind['direction']);
// Déplacement en mètres sur 1 heure
$dx = $speed * sin($dirRad) * 3600; // Est-Ouest (m)
$dy = $speed * cos($dirRad) * 3600; // Nord-Sud (m)
foreach ($points as &$pt) {
// Conversion m → degrés
$pt['lat'] += $dy / 111320;
$pt['lng'] += $dx / (111320 * cos(deg2rad($pt['lat'])));
}
unset($pt);
}
return $points;
}
/**
* Reconstruit un MULTIPOLYGON depuis un nuage de points via PostGIS.
*/
private function reconstructPolygon(array $points): ?string
{
if (count($points) < 3) {
return null;
}
$wktPoints = implode(',', array_map(
fn($p) => sprintf('%f %f', $p['lng'], $p['lat']),
$points
));
$row = $this->connection->fetchAssociative(
"SELECT ST_AsText(
ST_Multi(
ST_ConcaveHull(
ST_GeomFromText('MULTIPOINT({$wktPoints})', 4326),
:concavity
)
)
) AS wkt",
['concavity' => self::CONCAVITY]
);
return ($row !== false && $row['wkt']) ? 'SRID=4326;' . $row['wkt'] : null;
}
/**
* Calcule le vecteur de dérive moyen (composantes lat/lng) sur une période.
*/
private function avgDriftVector(array $windData, int $fromHour, int $toHour): array
{
$count = $toHour - $fromHour;
$sumLat = 0.0;
$sumLng = 0.0;
for ($h = $fromHour; $h < $toHour; $h++) {
$wind = $windData[$h] ?? ['speed' => 0, 'direction' => 0];
$speed = $wind['speed'] * self::WIND_FACTOR;
$dirRad = deg2rad($wind['direction']);
$sumLat += $speed * cos($dirRad);
$sumLng += $speed * sin($dirRad);
}
return ['lat' => $sumLat / max($count, 1), 'lng' => $sumLng / max($count, 1)];
}
/** Confiance décroissante avec l'horizon temporel. */
private function computeConfidence(int $horizon): float
{
return match ($horizon) {
6 => 0.85,
12 => 0.75,
24 => 0.60,
48 => 0.45,
default => 0.50,
};
}
}

View File

@@ -0,0 +1,266 @@
<?php
namespace App\Service\Forecast;
use App\Entity\CoastalPoint;
use App\Entity\ImpactScore;
use App\Entity\SargassumForecast;
use App\Entity\SargassumObservation;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Calcule et persiste les ImpactScore pour tous les CoastalPoints,
* en prenant en compte l'observation courante et les forecasts.
*
* Formule :
* score = distance_score(40) + density_score(30) + velocity_score(20) + trend_score(10)
*
* distance_score : max(0, 1 - distance/50km) × 40
* density_score : min(coverage_area_km2 / 100, 1) × 30
* velocity_score : max(0, approach_speed_km_h / 5) capped at 1, × 20
* trend_score : 10 si increasing, 5 si stable, 0 si decreasing
*/
class ImpactScoreService
{
private const MAX_DISTANCE_M = 50_000; // 50 km : au-delà score distance = 0
private const MAX_DENSITY_KM2 = 100; // 100 km² = densité maximale
private const MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
private const CACHE_TTL = 10_800; // 3h
public function __construct(
private Connection $connection,
private EntityManagerInterface $em,
private CacheInterface $cache,
private LoggerInterface $logger,
) {}
/**
* Calcule et sauvegarde les scores pour tous les CoastalPoints
* à partir d'une observation et de ses forecasts.
*/
public function computeForObservation(SargassumObservation $observation, array $forecasts): void
{
$spots = $this->em->getRepository(CoastalPoint::class)->findAll();
if (empty($spots)) {
$this->logger->info('No coastal points found, skipping score computation');
return;
}
$obsId = (string) $observation->getId();
$forecastH6 = $this->findForecastByHorizon($forecasts, 6);
foreach ($spots as $spot) {
$spotId = (string) $spot->getId();
try {
$score = $this->computeScore($spot, $obsId, $forecastH6);
$this->persistScore($spot, $score);
$this->invalidateCache($spotId);
} catch (\Throwable $e) {
$this->logger->error('Score computation failed', [
'spot' => $spot->getName(),
'error' => $e->getMessage(),
]);
}
}
$this->em->flush();
}
/**
* Retourne le score mis en cache pour un spot (TTL 3h).
*/
public function getCachedScore(string $spotId): ?array
{
$key = $this->cacheKey($spotId);
return $this->cache->get($key, function (ItemInterface $item) use ($spotId) {
$item->expiresAfter(self::CACHE_TTL);
return $this->loadLatestScore($spotId);
});
}
// -------------------------------------------------------------------------
private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array
{
$spotId = (string) $spot->getId();
// Distance au sargassum le plus proche (mètres)
$distanceM = $this->getDistanceToNearestSargassum($spotId, $obsId);
if ($distanceM === null) {
return $this->emptyScore();
}
$coverageKm2 = $this->getCoverageArea($obsId);
// Vitesse d'approche : delta distance entre now et H+6 (km/h)
$approachKmh = 0.0;
if ($forecastH6 !== null) {
$distH6M = $this->getDistanceToNearestForecast($spotId, (string) $forecastH6->getId());
if ($distH6M !== null) {
$approachKmh = ($distanceM - $distH6M) / 1000 / 6; // km/h (positif = approche)
}
}
// Tendance
$previousDistance = $this->getPreviousDistance($spotId);
$trend = match (true) {
$previousDistance === null => 'stable',
$distanceM < $previousDistance * 0.9 => 'increasing',
$distanceM > $previousDistance * 1.1 => 'decreasing',
default => 'stable',
};
// Calcul des composantes
$distanceScore = max(0.0, 1.0 - $distanceM / self::MAX_DISTANCE_M) * 40;
$densityScore = min($coverageKm2 / self::MAX_DENSITY_KM2, 1.0) * 30;
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore);
$total = max(0, min(100, $total));
return [
'score' => $total,
'level' => $this->scoreToLevel($total),
'distance' => round($distanceM / 1000, 2),
'density' => round($coverageKm2, 2),
'trend' => $trend,
];
}
private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT ST_Distance(
cp.geometry::geography,
o.geometry::geography
) AS distance_m
FROM coastal_point cp, sargassum_observation o
WHERE cp.id = :spotId AND o.id = :obsId',
['spotId' => $spotId, 'obsId' => $obsId]
);
return ($row !== false) ? (float) $row['distance_m'] : null;
}
private function getDistanceToNearestForecast(string $spotId, string $forecastId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT ST_Distance(
cp.geometry::geography,
f.geometry::geography
) AS distance_m
FROM coastal_point cp, sargassum_forecast f
WHERE cp.id = :spotId AND f.id = :forecastId',
['spotId' => $spotId, 'forecastId' => $forecastId]
);
return ($row !== false) ? (float) $row['distance_m'] : null;
}
private function getCoverageArea(string $obsId): float
{
$row = $this->connection->fetchAssociative(
'SELECT coverage_area FROM sargassum_observation WHERE id = :id',
['id' => $obsId]
);
return ($row !== false && $row['coverage_area'] !== null) ? (float) $row['coverage_area'] : 0.0;
}
private function getPreviousDistance(string $spotId): ?float
{
$row = $this->connection->fetchAssociative(
'SELECT distance_to_nearest_sargassum
FROM impact_score
WHERE coastal_point_id = :spotId
ORDER BY timestamp DESC
LIMIT 1',
['spotId' => $spotId]
);
return ($row !== false && $row['distance_to_nearest_sargassum'] !== null)
? (float) $row['distance_to_nearest_sargassum']
: null;
}
private function persistScore(CoastalPoint $spot, array $data): void
{
$s = new ImpactScore();
$s->setCoastalPoint($spot);
$s->setScore($data['score']);
$s->setLevel($data['level']);
$s->setDistanceToNearestSargassum($data['distance']);
$s->setDensityEstimate($data['density']);
$s->setTrend($data['trend']);
$this->em->persist($s);
}
private function loadLatestScore(string $spotId): ?array
{
$row = $this->connection->fetchAssociative(
'SELECT score, level, distance_to_nearest_sargassum AS distance,
density_estimate AS density, trend, timestamp AS computed_at
FROM impact_score
WHERE coastal_point_id = :spotId
ORDER BY timestamp DESC LIMIT 1',
['spotId' => $spotId]
);
if ($row === false) {
return null;
}
return [
'value' => (int) $row['score'],
'level' => $row['level'],
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
'density' => $row['density'] !== null ? (float) $row['density'] : null,
'trend' => $row['trend'],
'computedAt' => $row['computed_at'],
];
}
private function invalidateCache(string $spotId): void
{
$this->cache->delete($this->cacheKey($spotId));
}
private function cacheKey(string $spotId): string
{
return 'impact_score_' . str_replace('-', '_', $spotId);
}
private function scoreToLevel(int $score): string
{
return match (true) {
$score <= 30 => 'low',
$score <= 70 => 'medium',
default => 'high',
};
}
private function emptyScore(): array
{
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
}
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
{
foreach ($forecasts as $f) {
if ($f instanceof SargassumForecast && $f->getTimeHorizon() === $horizon) {
return $f;
}
}
return null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Service\Weather;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Récupère les données de vent horaire depuis Open-Meteo (gratuit, sans clé).
* Utilisé pour la simulation de dérive des sargasses.
*/
class OpenMeteoClient
{
private const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
public function __construct(private HttpClientInterface $httpClient) {}
/**
* Retourne les vecteurs de vent heure par heure pour un point géographique.
* Période couverte : 3 jours (suffisant pour H+48).
*
* @return array<int, array{time: string, speed: float, direction: float}>
* Tableau indexé par heure UTC (0..71)
*/
public function getHourlyWind(float $lat, float $lng): array
{
$response = $this->httpClient->request('GET', self::BASE_URL, [
'query' => [
'latitude' => $lat,
'longitude' => $lng,
'hourly' => 'wind_speed_10m,wind_direction_10m',
'wind_speed_unit' => 'ms',
'forecast_days' => 3,
'timezone' => 'UTC',
],
]);
$data = $response->toArray();
$hourly = $data['hourly'];
$result = [];
foreach ($hourly['time'] as $i => $time) {
$result[] = [
'time' => $time,
'speed' => (float) ($hourly['wind_speed_10m'][$i] ?? 0),
'direction' => (float) ($hourly['wind_direction_10m'][$i] ?? 0),
];
}
return $result;
}
}

View File

@@ -83,35 +83,34 @@
### Pipeline — simulation de dérive
- [ ] Accès NOAA GRIB (vent + courant)
- [ ] Échantillonnage polygone (centroids + random sampling)
- [ ] Application vecteur dérive par point
- [ ] Reconstruction polygone (convex hull / alpha shape)
- [ ] Génération horizons H+6, H+12, H+24, H+48
- [ ] Persistance `SargassumForecast`
- [ ] Génération vector tiles prédictions (Tippecanoe)
- [x] Vent horaire via Open-Meteo (gratuit, sans clé, JSON)
- [x] Échantillonnage PostGIS ST_GeneratePoints (30 points internes)
- [x] Déplacement horaire itératif (modèle Stokes 3% vent)
- [x] Reconstruction ST_ConcaveHull (PostGIS 3.4)
- [x] Génération horizons H+6, H+12, H+24, H+48 avec confiance décroissante
- [x] Persistance `SargassumForecast`
- [ ] Génération vector tiles prédictions (Tippecanoe) — différé
### Calcul ImpactScore
- [ ] Calcul score par `CoastalPoint` (distance, densité, vitesse d'approche, tendance)
- [ ] Persistance `ImpactScore`
- [ ] Mise en cache Redis (TTL 3h)
- [x] Score par `CoastalPoint` : distance(40) + densité(30) + vitesse(20) + tendance(10)
- [x] Persistance `ImpactScore`
- [x] Mise en cache Redis (TTL 3h) via ImpactScoreService
### API Backend
- [ ] `GET /api/spots/{id}/score` — score courant + horizons
- [ ] `GET /api/spots/{id}/score?at={datetime}` — score à un instant
- [ ] `GET /api/forecasts?observationId=` — prédictions par observation
- [ ] `GET /api/forecasts?bbox=&horizon=` — prédictions par zone + horizon
- [x] `GET /api/spots/{id}/score` — score courant + horizons H+6/12/24/48
- [x] `GET /api/spots/{id}/score?at={datetime}` — score à un instant
- [x] `GET /api/forecasts?bbox=&horizon=` — prédictions par zone + horizon (API Platform)
### Frontend React
- [ ] Spot Mode complet (OK / Risque / Impact par horizon)
- [ ] Slider temporel (Now → +6h → +12h → +24h → +48h)
- [ ] Mise à jour dynamique polygones + score sur slider
- [ ] Gradients radiaux densité
- [ ] Animation légère sur polygones de prédiction
- [ ] Vue mobile optimisée
- [x] Slider temporel (Now → +6h → +12h → +24h → +48h)
- [x] Spot Mode complet par horizon (OK / Risque / Impact + confiance)
- [x] Mise à jour dynamique polygones + score sur slider
- [x] Couche forecasts (violet pointillé) vs observations (orange plein)
- [ ] Gradients radiaux densité — différé
- [ ] Vue mobile optimisée — différé
---

View File

@@ -1,19 +1,26 @@
import { useState } from 'react';
import SargassesMap from './components/Map/SargassesMap';
import SpotPanel from './components/SpotPanel/SpotPanel';
import TimelineSlider from './components/Timeline/TimelineSlider';
import './App.css';
export default function App() {
const [selectedSpot, setSelectedSpot] = useState(null);
const [activeStep, setActiveStep] = useState('now');
return (
<div className="app">
<TimelineSlider activeStep={activeStep} onChange={setActiveStep} />
<SargassesMap
onSpotSelect={setSelectedSpot}
selectedSpotId={selectedSpot?.id}
activeStep={activeStep}
/>
<SpotPanel
spot={selectedSpot}
activeStep={activeStep}
onClose={() => setSelectedSpot(null)}
/>
</div>

View File

@@ -33,6 +33,10 @@ export const api = {
list: (bbox, date, source) => get('/observations', { bbox, date, source }),
get: (id) => get(`/observations/${id}`),
},
forecasts: {
list: (bbox, horizon) => get('/forecasts', { bbox, horizon }),
get: (id) => get(`/forecasts/${id}`),
},
feedback: {
create: (data) => post('/feedback', data),
},

View File

@@ -3,99 +3,88 @@ import Map, { Layer, Marker, NavigationControl, Source } from 'react-map-gl/mapl
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 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,
// Styles des couches selon le type (observation vs prédiction)
const layerStyles = (isPrediction) => ({
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,
},
},
};
const OBSERVATION_LINE = {
id: 'observations-line',
type: 'line',
source: 'observations',
paint: {
'line-color': '#d97706',
'line-width': 2,
line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
type: 'line',
source: isPrediction ? 'forecasts' : 'observations',
paint: {
'line-color': isPrediction ? '#6366f1' : '#d97706',
'line-width': 2,
'line-dasharray': isPrediction ? [4, 3] : [1],
},
},
};
});
export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
const OBS_STYLES = layerStyles(false);
const FORE_STYLES = layerStyles(true);
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep }) {
const mapRef = useRef(null);
const [spots, setSpots] = useState([]);
const [observationGeoJSON, setObs] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [spots, setSpots] = useState([]);
const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [foreGeoJSON, setForeGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW);
// Charge les spots côtiers une seule fois
// Spots côtiers : chargement unique
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 loadLayers = 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(',');
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
.map(v => v.toFixed(4)).join(',');
api.observations.list(bbox)
.then(data => {
if (!data.items?.length) { setObs(null); return; }
if (activeStep === 'now') {
// Observations réelles
api.observations.list(bbox)
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
.catch(console.error);
setForeGeoJSON(null);
} else {
// Forecasts pour cet horizon
const horizon = horizonToInt(activeStep);
api.forecasts.list(bbox, horizon)
.then(data => {
setForeGeoJSON(toFeatureCollection(data.items));
setObsGeoJSON(null);
})
.catch(console.error);
}
}, [activeStep]);
// 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,
},
}));
useEffect(() => { loadLayers(); }, [loadLayers]);
setObs({ type: 'FeatureCollection', features });
})
.catch(console.error);
}, []);
const toFeatureCollection = (items = []) => {
const features = (items ?? [])
.filter(i => i.geometry)
.map(i => ({ type: 'Feature', geometry: i.geometry, properties: {} }));
return features.length ? { type: 'FeatureCollection', features } : null;
};
// 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] };
const c = spot.geometry?.coordinates;
return c ? { lng: c[0], lat: c[1] } : null;
};
return (
@@ -103,26 +92,33 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
ref={mapRef}
{...viewState}
onMove={e => setViewState(e.viewState)}
onMoveEnd={onMoveEnd}
onLoad={loadObservations}
onMoveEnd={loadLayers}
onLoad={loadLayers}
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} />
{/* Couche observations (now) */}
{obsGeoJSON && (
<Source id="observations" type="geojson" data={obsGeoJSON}>
<Layer {...OBS_STYLES.fill} />
<Layer {...OBS_STYLES.line} />
</Source>
)}
{/* Marqueurs spots côtiers */}
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
{/* Couche prédictions (H+6/12/24/48) */}
{foreGeoJSON && (
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
<Layer {...FORE_STYLES.fill} />
<Layer {...FORE_STYLES.line} />
</Source>
)}
{/* Marqueurs spots */}
{spots.map(spot => {
const coords = getCoords(spot);
if (!coords) return null;
const isSelected = spot.id === selectedSpotId;
return (

View File

@@ -38,6 +38,20 @@
.spot-panel__name { margin: 0; font-size: 18px; font-weight: 600; }
.spot-panel__region { margin: 2px 0 0; font-size: 13px; color: #94a3b8; }
/* Horizon label */
.spot-panel__horizon-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: .05em;
}
.spot-panel__confidence { font-weight: 400; color: #64748b; }
/* Score */
.spot-panel__score { margin-bottom: 16px; }

View File

@@ -3,73 +3,84 @@ 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: '🎣' };
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);
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
export default function SpotPanel({ spot, activeStep, onClose }) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(null);
useEffect(() => {
if (!spot) return;
setScore(undefined);
setData(undefined);
setError(null);
api.spots.score(spot.id)
.then(data => setScore(data))
.then(d => setData(d))
.catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]);
if (!spot) return null;
const icon = TYPE_ICON[spot.type] ?? '📍';
const lvl = score?.score?.level;
// Score à afficher selon l'étape active
const displayScore = activeStep === 'now'
? data?.score
: (data?.horizons?.[activeStep] ? {
value: data.horizons[activeStep].score,
level: data.horizons[activeStep].level,
distance: data.horizons[activeStep].distanceKm,
confidence: data.horizons[activeStep].confidence,
} : null);
const lvl = displayScore?.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>
<span className="spot-panel__icon">{TYPE_ICON[spot.type] ?? '📍'}</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>}
<div className="spot-panel__horizon-label">
{HORIZON_LABELS[activeStep] ?? activeStep}
{activeStep !== 'now' && displayScore?.confidence != null && (
<span className="spot-panel__confidence">
Confiance : {Math.round(displayScore.confidence * 100)}%
</span>
)}
</div>
<div className="spot-panel__score">
{data === 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>
{data !== undefined && !error && (
displayScore === null
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</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>
<span className="spot-panel__level-value">{displayScore.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>
</>
{displayScore.distance != null && (
<><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></>
)}
{score.score.trend && (
<>
<dt>Tendance</dt>
<dd>{score.score.trend}</dd>
</>
{activeStep === 'now' && displayScore.trend && (
<><dt>Tendance</dt><dd>{displayScore.trend}</dd></>
)}
{score.score.computedAt && (
<>
<dt>Calculé le</dt>
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
</>
{activeStep === 'now' && displayScore.computedAt && (
<><dt>Calculé le</dt>
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
)}
</dl>
</>
@@ -83,23 +94,19 @@ export default function SpotPanel({ spot, onClose }) {
}
function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false);
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);
if (coords) {
await api.feedback.create({
lat: coords[1], lng: coords[0],
hasSeaweed, coastalPointId: spot.id,
}).catch(console.error);
}
setLoading(false);
setSent(true);
};

View File

@@ -0,0 +1,31 @@
.timeline {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 4px;
z-index: 10;
}
.timeline__step {
padding: 6px 14px;
border: none;
border-radius: 8px;
background: transparent;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background .15s, color .15s;
white-space: nowrap;
}
.timeline__step:hover { background: #334155; color: #f1f5f9; }
.timeline__step--active {
background: #0ea5e9;
color: #fff;
}

View File

@@ -0,0 +1,25 @@
import './TimelineSlider.css';
const STEPS = [
{ value: 'now', label: 'Maintenant' },
{ value: 'h6', label: '+6h' },
{ value: 'h12', label: '+12h' },
{ value: 'h24', label: '+24h' },
{ value: 'h48', label: '+48h' },
];
export default function TimelineSlider({ activeStep, onChange }) {
return (
<div className="timeline">
{STEPS.map((step, i) => (
<button
key={step.value}
className={`timeline__step ${activeStep === step.value ? 'timeline__step--active' : ''}`}
onClick={() => onChange(step.value)}
>
{step.label}
</button>
))}
</div>
);
}