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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<script type="module" crossorigin src="/assets/index-Brx4kn-A.js"></script> <script type="module" crossorigin src="/assets/index-BV2EXFwR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLFkLo67.css"> <link rel="stylesheet" crossorigin href="/assets/index-jpyCrywh.css">
</head> </head>
<body> <body>
<div id="root"></div> <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; namespace App\Controller;
use App\Entity\CoastalPoint; use App\Entity\CoastalPoint;
use App\Entity\ImpactScore; use App\Service\Forecast\ImpactScoreService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -13,55 +14,135 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/spots', name: 'api_spots_')] #[Route('/api/spots', name: 'api_spots_')]
class SpotScoreController extends AbstractController 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 * GET /api/spots/{id}/score
* *
* Retourne le score courant pour un CoastalPoint. * Retourne le score courant + les scores par horizon (H+6/12/24/48).
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ (score à un instant donné) * Utilise le cache Redis (TTL 3h).
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ
*/ */
#[Route('/{id}/score', name: 'score', methods: ['GET'])] #[Route('/{id}/score', name: 'score', methods: ['GET'])]
public function score(string $id, Request $request): JsonResponse public function score(string $id, Request $request): JsonResponse
{ {
$spot = $this->em->getRepository(CoastalPoint::class)->find($id); $spot = $this->em->getRepository(CoastalPoint::class)->find($id);
if ($spot === null) { if ($spot === null) {
return $this->json(['error' => 'Spot not found'], 404); 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'); $atParam = $request->query->get('at');
// Si ?at= spécifié, on ne peut pas utiliser le cache
if ($atParam !== null) { if ($atParam !== null) {
try { try {
$at = new \DateTimeImmutable($atParam); $at = new \DateTimeImmutable($atParam);
$qb->andWhere('s.timestamp <= :at')->setParameter('at', $at);
} catch (\Exception) { } catch (\Exception) {
return $this->json(['error' => 'Invalid "at" parameter format'], 400); 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([ return $this->json([
'spotId' => (string) $spot->getId(), 'spotId' => (string) $spot->getId(),
'name' => $spot->getName(), 'name' => $spot->getName(),
'type' => $spot->getType(), 'type' => $spot->getType(),
'region' => $spot->getRegion(), 'region' => $spot->getRegion(),
'score' => $latest ? [ 'score' => $current,
'value' => $latest->getScore(), 'horizons' => $this->loadHorizonScores($id),
'level' => $latest->getLevel(),
'distance' => $latest->getDistanceToNearestSargassum(),
'density' => $latest->getDensityEstimate(),
'trend' => $latest->getTrend(),
'computedAt' => $latest->getTimestamp()?->format(\DateTimeInterface::ATOM),
] : null,
]); ]);
} }
// -------------------------------------------------------------------------
/**
* 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 ### Pipeline — simulation de dérive
- [ ] Accès NOAA GRIB (vent + courant) - [x] Vent horaire via Open-Meteo (gratuit, sans clé, JSON)
- [ ] Échantillonnage polygone (centroids + random sampling) - [x] Échantillonnage PostGIS ST_GeneratePoints (30 points internes)
- [ ] Application vecteur dérive par point - [x] Déplacement horaire itératif (modèle Stokes 3% vent)
- [ ] Reconstruction polygone (convex hull / alpha shape) - [x] Reconstruction ST_ConcaveHull (PostGIS 3.4)
- [ ] Génération horizons H+6, H+12, H+24, H+48 - [x] Génération horizons H+6, H+12, H+24, H+48 avec confiance décroissante
- [ ] Persistance `SargassumForecast` - [x] Persistance `SargassumForecast`
- [ ] Génération vector tiles prédictions (Tippecanoe) - [ ] Génération vector tiles prédictions (Tippecanoe) — différé
### Calcul ImpactScore ### Calcul ImpactScore
- [ ] Calcul score par `CoastalPoint` (distance, densité, vitesse d'approche, tendance) - [x] Score par `CoastalPoint` : distance(40) + densité(30) + vitesse(20) + tendance(10)
- [ ] Persistance `ImpactScore` - [x] Persistance `ImpactScore`
- [ ] Mise en cache Redis (TTL 3h) - [x] Mise en cache Redis (TTL 3h) via ImpactScoreService
### API Backend ### API Backend
- [ ] `GET /api/spots/{id}/score` — score courant + horizons - [x] `GET /api/spots/{id}/score` — score courant + horizons H+6/12/24/48
- [ ] `GET /api/spots/{id}/score?at={datetime}` — score à un instant - [x] `GET /api/spots/{id}/score?at={datetime}` — score à un instant
- [ ] `GET /api/forecasts?observationId=` — prédictions par observation - [x] `GET /api/forecasts?bbox=&horizon=` — prédictions par zone + horizon (API Platform)
- [ ] `GET /api/forecasts?bbox=&horizon=` — prédictions par zone + horizon
### Frontend React ### Frontend React
- [ ] Spot Mode complet (OK / Risque / Impact par horizon) - [x] Slider temporel (Now → +6h → +12h → +24h → +48h)
- [ ] Slider temporel (Now → +6h → +12h → +24h → +48h) - [x] Spot Mode complet par horizon (OK / Risque / Impact + confiance)
- [ ] Mise à jour dynamique polygones + score sur slider - [x] Mise à jour dynamique polygones + score sur slider
- [ ] Gradients radiaux densité - [x] Couche forecasts (violet pointillé) vs observations (orange plein)
- [ ] Animation légère sur polygones de prédiction - [ ] Gradients radiaux densité — différé
- [ ] Vue mobile optimisée - [ ] Vue mobile optimisée — différé
--- ---

View File

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

View File

@@ -33,6 +33,10 @@ export const api = {
list: (bbox, date, source) => get('/observations', { bbox, date, source }), list: (bbox, date, source) => get('/observations', { bbox, date, source }),
get: (id) => get(`/observations/${id}`), get: (id) => get(`/observations/${id}`),
}, },
forecasts: {
list: (bbox, horizon) => get('/forecasts', { bbox, horizon }),
get: (id) => get(`/forecasts/${id}`),
},
feedback: { feedback: {
create: (data) => post('/feedback', data), 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 'maplibre-gl/dist/maplibre-gl.css';
import { api } from '../../api/client'; 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 = { // Styles des couches selon le type (observation vs prédiction)
low: '#22c55e', // vert const layerStyles = (isPrediction) => ({
medium: '#f59e0b', // orange fill: {
high: '#ef4444', // rouge id: isPrediction ? 'forecasts-fill' : 'observations-fill',
}; type: 'fill',
source: isPrediction ? 'forecasts' : 'observations',
// Styles des couches MapLibre pour les observations paint: {
const OBSERVATION_FILL = { 'fill-color': isPrediction ? '#818cf8' : '#f59e0b',
id: 'observations-fill', 'fill-opacity': isPrediction ? 0.25 : 0.35,
type: 'fill', },
source: 'observations',
paint: {
'fill-color': '#f59e0b',
'fill-opacity': 0.35,
}, },
}; line: {
id: isPrediction ? 'forecasts-line' : 'observations-line',
const OBSERVATION_LINE = { type: 'line',
id: 'observations-line', source: isPrediction ? 'forecasts' : 'observations',
type: 'line', paint: {
source: 'observations', 'line-color': isPrediction ? '#6366f1' : '#d97706',
paint: { 'line-width': 2,
'line-color': '#d97706', 'line-dasharray': isPrediction ? [4, 3] : [1],
'line-width': 2, },
}, },
}; });
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 mapRef = useRef(null);
const [spots, setSpots] = useState([]); const [spots, setSpots] = useState([]);
const [observationGeoJSON, setObs] = useState(null); const [obsGeoJSON, setObsGeoJSON] = useState(null);
const [viewState, setViewState] = useState(INITIAL_VIEW); 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(() => { useEffect(() => {
api.spots.list() api.spots.list()
.then(data => setSpots(data['hydra:member'] ?? data.member ?? [])) .then(data => setSpots(data['hydra:member'] ?? data.member ?? []))
.catch(console.error); .catch(console.error);
}, []); }, []);
// Charge les observations quand la carte bouge (debounced) const loadLayers = useCallback(() => {
const loadObservations = useCallback(() => {
const map = mapRef.current?.getMap(); const map = mapRef.current?.getMap();
if (!map) return; if (!map) return;
const bounds = map.getBounds(); const b = map.getBounds();
const bbox = [ const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
bounds.getWest().toFixed(4), .map(v => v.toFixed(4)).join(',');
bounds.getSouth().toFixed(4),
bounds.getEast().toFixed(4),
bounds.getNorth().toFixed(4),
].join(',');
api.observations.list(bbox) if (activeStep === 'now') {
.then(data => { // Observations réelles
if (!data.items?.length) { setObs(null); return; } 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 useEffect(() => { loadLayers(); }, [loadLayers]);
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 }); const toFeatureCollection = (items = []) => {
}) const features = (items ?? [])
.catch(console.error); .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 getCoords = (spot) => {
const coords = spot.geometry?.coordinates; const c = spot.geometry?.coordinates;
if (!coords) return null; return c ? { lng: c[0], lat: c[1] } : null;
return { lng: coords[0], lat: coords[1] };
}; };
return ( return (
@@ -103,26 +92,33 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId }) {
ref={mapRef} ref={mapRef}
{...viewState} {...viewState}
onMove={e => setViewState(e.viewState)} onMove={e => setViewState(e.viewState)}
onMoveEnd={onMoveEnd} onMoveEnd={loadLayers}
onLoad={loadObservations} onLoad={loadLayers}
mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" mapStyle="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
style={{ width: '100%', height: '100vh' }} style={{ width: '100%', height: '100vh' }}
> >
<NavigationControl position="top-right" /> <NavigationControl position="top-right" />
{/* Couche observations sargasses */} {/* Couche observations (now) */}
{observationGeoJSON && ( {obsGeoJSON && (
<Source id="observations" type="geojson" data={observationGeoJSON}> <Source id="observations" type="geojson" data={obsGeoJSON}>
<Layer {...OBSERVATION_FILL} /> <Layer {...OBS_STYLES.fill} />
<Layer {...OBSERVATION_LINE} /> <Layer {...OBS_STYLES.line} />
</Source> </Source>
)} )}
{/* Marqueurs spots côtiers */} {/* Couche prédictions (H+6/12/24/48) */}
{spots.map(spot => { {foreGeoJSON && (
const coords = getCoords(spot); <Source id="forecasts" type="geojson" data={foreGeoJSON}>
if (!coords) return null; <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; const isSelected = spot.id === selectedSpotId;
return ( return (

View File

@@ -38,6 +38,20 @@
.spot-panel__name { margin: 0; font-size: 18px; font-weight: 600; } .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__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 */ /* Score */
.spot-panel__score { margin-bottom: 16px; } .spot-panel__score { margin-bottom: 16px; }

View File

@@ -3,73 +3,84 @@ import { api } from '../../api/client';
import './SpotPanel.css'; import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' }; const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' }; const LEVEL_CLASS = { low: 'level--ok', medium: 'level--risk', high: 'level--impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' }; const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
export default function SpotPanel({ spot, onClose }) { const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
const [score, setScore] = useState(undefined); // undefined = loading
const [error, setError] = useState(null); export default function SpotPanel({ spot, activeStep, onClose }) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
if (!spot) return; if (!spot) return;
setScore(undefined); setData(undefined);
setError(null); setError(null);
api.spots.score(spot.id) api.spots.score(spot.id)
.then(data => setScore(data)) .then(d => setData(d))
.catch(() => setError('Impossible de charger le score.')); .catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]); }, [spot?.id]);
if (!spot) return null; if (!spot) return null;
const icon = TYPE_ICON[spot.type] ?? '📍'; // Score à afficher selon l'étape active
const lvl = score?.score?.level; 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 ( return (
<div className="spot-panel"> <div className="spot-panel">
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button> <button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button>
<div className="spot-panel__header"> <div className="spot-panel__header">
<span className="spot-panel__icon">{icon}</span> <span className="spot-panel__icon">{TYPE_ICON[spot.type] ?? '📍'}</span>
<div> <div>
<h2 className="spot-panel__name">{spot.name}</h2> <h2 className="spot-panel__name">{spot.name}</h2>
<p className="spot-panel__region">{spot.region}</p> <p className="spot-panel__region">{spot.region}</p>
</div> </div>
</div> </div>
<div className="spot-panel__score"> <div className="spot-panel__horizon-label">
{score === undefined && <p className="spot-panel__loading">Chargement</p>} {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>} {error && <p className="spot-panel__error">{error}</p>}
{score !== undefined && !error && ( {data !== undefined && !error && (
score.score === null displayScore === null
? <p className="spot-panel__no-data">Aucune donnée disponible pour ce spot.</p> ? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
: ( : (
<> <>
<div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}> <div className={`spot-panel__level ${LEVEL_CLASS[lvl] ?? ''}`}>
<span className="spot-panel__level-label">{LEVEL_LABEL[lvl] ?? lvl}</span> <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> </div>
<dl className="spot-panel__details"> <dl className="spot-panel__details">
{score.score.distance != null && ( {displayScore.distance != null && (
<> <><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></>
<dt>Distance sargasses</dt>
<dd>{score.score.distance.toFixed(1)} km</dd>
</>
)} )}
{score.score.trend && ( {activeStep === 'now' && displayScore.trend && (
<> <><dt>Tendance</dt><dd>{displayScore.trend}</dd></>
<dt>Tendance</dt>
<dd>{score.score.trend}</dd>
</>
)} )}
{score.score.computedAt && ( {activeStep === 'now' && displayScore.computedAt && (
<> <><dt>Calculé le</dt>
<dt>Calculé le</dt> <dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
<dd>{new Date(score.score.computedAt).toLocaleString('fr-FR')}</dd>
</>
)} )}
</dl> </dl>
</> </>
@@ -83,23 +94,19 @@ export default function SpotPanel({ spot, onClose }) {
} }
function FeedbackButton({ spot }) { function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async (hasSeaweed) => { const submit = async (hasSeaweed) => {
if (loading || sent) return; if (loading || sent) return;
setLoading(true); setLoading(true);
const coords = spot.geometry?.coordinates; const coords = spot.geometry?.coordinates;
if (!coords) return; if (coords) {
await api.feedback.create({
await api.feedback.create({ lat: coords[1], lng: coords[0],
lat: coords[1], hasSeaweed, coastalPointId: spot.id,
lng: coords[0], }).catch(console.error);
hasSeaweed, }
coastalPointId: spot.id,
}).catch(console.error);
setLoading(false); setLoading(false);
setSent(true); 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>
);
}