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:
9
backend/public/spa/assets/index-BV2EXFwR.js
Normal file
9
backend/public/spa/assets/index-BV2EXFwR.js
Normal file
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
@@ -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>
|
||||
|
||||
89
backend/src/Command/ComputeForecastsCommand.php
Normal file
89
backend/src/Command/ComputeForecastsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
'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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
227
backend/src/Service/Forecast/DriftSimulationService.php
Normal file
227
backend/src/Service/Forecast/DriftSimulationService.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
266
backend/src/Service/Forecast/ImpactScoreService.php
Normal file
266
backend/src/Service/Forecast/ImpactScoreService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
backend/src/Service/Weather/OpenMeteoClient.php
Normal file
51
backend/src/Service/Weather/OpenMeteoClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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é
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
// Styles des couches selon le type (observation vs prédiction)
|
||||
const layerStyles = (isPrediction) => ({
|
||||
fill: {
|
||||
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
|
||||
type: 'fill',
|
||||
source: 'observations',
|
||||
source: isPrediction ? 'forecasts' : 'observations',
|
||||
paint: {
|
||||
'fill-color': '#f59e0b',
|
||||
'fill-opacity': 0.35,
|
||||
'fill-color': isPrediction ? '#818cf8' : '#f59e0b',
|
||||
'fill-opacity': isPrediction ? 0.25 : 0.35,
|
||||
},
|
||||
};
|
||||
|
||||
const OBSERVATION_LINE = {
|
||||
id: 'observations-line',
|
||||
},
|
||||
line: {
|
||||
id: isPrediction ? 'forecasts-line' : 'observations-line',
|
||||
type: 'line',
|
||||
source: 'observations',
|
||||
source: isPrediction ? 'forecasts' : 'observations',
|
||||
paint: {
|
||||
'line-color': '#d97706',
|
||||
'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 [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(',');
|
||||
|
||||
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 => {
|
||||
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 });
|
||||
setForeGeoJSON(toFeatureCollection(data.items));
|
||||
setObsGeoJSON(null);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
}
|
||||
}, [activeStep]);
|
||||
|
||||
// Recharge les observations après chaque fin de mouvement
|
||||
const onMoveEnd = useCallback(() => {
|
||||
loadObservations();
|
||||
}, [loadObservations]);
|
||||
useEffect(() => { loadLayers(); }, [loadLayers]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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 */}
|
||||
{/* 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 (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -6,70 +6,81 @@ 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 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>
|
||||
</>
|
||||
@@ -89,17 +100,13 @@ function FeedbackButton({ spot }) {
|
||||
const submit = async (hasSeaweed) => {
|
||||
if (loading || sent) return;
|
||||
setLoading(true);
|
||||
|
||||
const coords = spot.geometry?.coordinates;
|
||||
if (!coords) return;
|
||||
|
||||
if (coords) {
|
||||
await api.feedback.create({
|
||||
lat: coords[1],
|
||||
lng: coords[0],
|
||||
hasSeaweed,
|
||||
coastalPointId: spot.id,
|
||||
lat: coords[1], lng: coords[0],
|
||||
hasSeaweed, coastalPointId: spot.id,
|
||||
}).catch(console.error);
|
||||
|
||||
}
|
||||
setLoading(false);
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
31
frontend/src/components/Timeline/TimelineSlider.css
Normal file
31
frontend/src/components/Timeline/TimelineSlider.css
Normal 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;
|
||||
}
|
||||
25
frontend/src/components/Timeline/TimelineSlider.jsx
Normal file
25
frontend/src/components/Timeline/TimelineSlider.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user