diff --git a/backend/migrations/Version20260403000000.php b/backend/migrations/Version20260403000000.php new file mode 100644 index 0000000..75fb7f6 --- /dev/null +++ b/backend/migrations/Version20260403000000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE impact_score ADD COLUMN eta_hours INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE impact_score DROP COLUMN IF EXISTS eta_hours'); + } +} diff --git a/backend/src/Controller/SpotScoreController.php b/backend/src/Controller/SpotScoreController.php index 3e7c16c..110c0c3 100644 --- a/backend/src/Controller/SpotScoreController.php +++ b/backend/src/Controller/SpotScoreController.php @@ -64,13 +64,13 @@ class SpotScoreController extends AbstractController /** * Score à un instant précis (sans cache). * - * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}|null */ 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 + density_estimate AS density, trend, eta_hours, timestamp AS computed_at FROM impact_score WHERE coastal_point_id = :spotId AND timestamp <= :at ORDER BY timestamp DESC LIMIT 1', @@ -131,16 +131,17 @@ class SpotScoreController extends AbstractController /** * @param array $row - * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string} + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string} */ 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, + 'distance' => $row['distance'] !== null ? (float) $row['distance'] : null, + 'density' => $row['density'] !== null ? (float) $row['density'] : null, 'trend' => $row['trend'], + 'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null, 'computedAt' => $row['computed_at'], ]; } diff --git a/backend/src/Entity/ImpactScore.php b/backend/src/Entity/ImpactScore.php index 42dbe75..cfca589 100644 --- a/backend/src/Entity/ImpactScore.php +++ b/backend/src/Entity/ImpactScore.php @@ -49,6 +49,13 @@ class ImpactScore #[ORM\Column(length: 20, nullable: true)] private ?string $trend = null; + /** + * Horizon (en heures) auquel les sargasses devraient atteindre le spot. + * 0 = impact en cours, null = aucun impact prévu dans les 48h. + */ + #[ORM\Column(nullable: true)] + private ?int $etaHours = null; + public function __construct() { $this->timestamp = new \DateTimeImmutable(); @@ -71,4 +78,6 @@ class ImpactScore public function setDensityEstimate(?float $d): static { $this->densityEstimate = $d; return $this; } public function getTrend(): ?string { return $this->trend; } public function setTrend(?string $trend): static { $this->trend = $trend; return $this; } + public function getEtaHours(): ?int { return $this->etaHours; } + public function setEtaHours(?int $etaHours): static { $this->etaHours = $etaHours; return $this; } } diff --git a/backend/src/Service/Forecast/ImpactScoreService.php b/backend/src/Service/Forecast/ImpactScoreService.php index cd4486d..c23de81 100644 --- a/backend/src/Service/Forecast/ImpactScoreService.php +++ b/backend/src/Service/Forecast/ImpactScoreService.php @@ -31,6 +31,7 @@ class ImpactScoreService 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 + private const ETA_BUFFER_M = 5_000; // 5 km : seuil "impact imminent" sur le spot // Bonus feedback : +8 pts si présence confirmée dans les 6h et dans rayon 20km private const FEEDBACK_RADIUS_M = 20_000; @@ -61,14 +62,13 @@ class ImpactScoreService return; } - $obsId = (string) $observation->getId(); - $forecastH6 = $this->findForecastByHorizon($forecasts, 6); + $obsId = (string) $observation->getId(); foreach ($spots as $spot) { $spotId = (string) $spot->getId(); try { - $score = $this->computeScore($spot, $obsId, $forecastH6); + $score = $this->computeScore($spot, $obsId, $forecasts); $this->persistScore($spot, $score); $this->invalidateCache($spotId); @@ -90,7 +90,7 @@ class ImpactScoreService /** * Retourne le score mis en cache pour un spot (TTL 3h). * - * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}|null */ public function getCachedScore(string $spotId): ?array { @@ -104,8 +104,11 @@ class ImpactScoreService // ------------------------------------------------------------------------- - /** @return array{score: int, level: string, distance: float|null, density: float, trend: string} */ - private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array + /** + * @param array $forecasts + * @return array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} + */ + private function computeScore(CoastalPoint $spot, string $obsId, array $forecasts): array { $spotId = (string) $spot->getId(); @@ -117,6 +120,7 @@ class ImpactScoreService } $coverageKm2 = $this->getCoverageArea($obsId); + $forecastH6 = $this->findForecastByHorizon($forecasts, 6); // Vitesse d'approche : delta distance entre now et H+6 (km/h) $approachKmh = 0.0; @@ -130,17 +134,20 @@ class ImpactScoreService // Tendance $previousDistance = $this->getPreviousDistance($spotId); $trend = match (true) { - $previousDistance === null => 'stable', + $previousDistance === null => 'stable', $distanceM < $previousDistance * 0.9 => 'increasing', $distanceM > $previousDistance * 1.1 => 'decreasing', - default => 'stable', + default => 'stable', }; + // ETA côtier + $etaHours = $this->computeEta($spotId, $distanceM, $forecasts); + // 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 }; + $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 }; // Bonus feedback terrain récent $feedbackBonus = $this->getFeedbackBonus($spotId); @@ -154,9 +161,37 @@ class ImpactScoreService 'distance' => round($distanceM / 1000, 2), 'density' => round($coverageKm2, 2), 'trend' => $trend, + 'etaHours' => $etaHours, ]; } + /** + * Calcule l'horizon (en h) auquel les sargasses devraient atteindre le spot. + * Retourne 0 si impact déjà en cours, null si aucun impact prévu dans 48h. + * + * @param array $forecasts + */ + private function computeEta(string $spotId, float $currentDistanceM, array $forecasts): ?int + { + if ($currentDistanceM < self::ETA_BUFFER_M) { + return 0; // impact en cours + } + + foreach ([6, 12, 24, 48] as $horizon) { + $forecast = $this->findForecastByHorizon($forecasts, $horizon); + if ($forecast === null) { + continue; + } + + $distM = $this->getDistanceToNearestForecast($spotId, (string) $forecast->getId()); + if ($distM !== null && $distM < self::ETA_BUFFER_M) { + return $horizon; + } + } + + return null; + } + private function getDistanceToNearestSargassum(string $spotId, string $obsId): ?float { $row = $this->connection->fetchAssociative( @@ -221,7 +256,7 @@ class ImpactScoreService : null; } - /** @param array{score: int, level: string, distance: float|null, density: float, trend: string} $data */ + /** @param array{score: int, level: string, distance: float|null, density: float, trend: string, etaHours: int|null} $data */ private function persistScore(CoastalPoint $spot, array $data): void { $s = new ImpactScore(); @@ -231,16 +266,17 @@ class ImpactScoreService $s->setDistanceToNearestSargassum($data['distance']); $s->setDensityEstimate($data['density']); $s->setTrend($data['trend']); + $s->setEtaHours($data['etaHours']); $this->em->persist($s); } - /** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null */ + /** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}|null */ 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 + density_estimate AS density, trend, eta_hours, timestamp AS computed_at FROM impact_score WHERE coastal_point_id = :spotId ORDER BY timestamp DESC LIMIT 1', @@ -254,9 +290,10 @@ class ImpactScoreService return [ 'value' => (int) $row['score'], 'level' => $row['level'], - 'distance' => $row['distance'] !== null ? (float) $row['distance'] : null, - 'density' => $row['density'] !== null ? (float) $row['density'] : null, + 'distance' => $row['distance'] !== null ? (float) $row['distance'] : null, + 'density' => $row['density'] !== null ? (float) $row['density'] : null, 'trend' => $row['trend'], + 'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null, 'computedAt' => $row['computed_at'], ]; } @@ -280,10 +317,10 @@ class ImpactScoreService }; } - /** @return array{score: int, level: string, distance: null, density: float, trend: string} */ + /** @return array{score: int, level: string, distance: null, density: float, trend: string, etaHours: null} */ private function emptyScore(): array { - return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable']; + return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable', 'etaHours' => null]; } private function getFeedbackBonus(string $spotId): float diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css index 0e7f754..5e9c467 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.css +++ b/frontend/src/components/SpotPanel/SpotPanel.css @@ -124,6 +124,27 @@ .gauge--medium .spot-panel__gauge-fill { background: #eab308; } .gauge--high .spot-panel__gauge-fill { background: #ef4444; } +/* ETA */ +.spot-panel__eta { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + border-radius: 14px; + border: 2px solid rgba(0,0,0,0.15); + margin-bottom: 10px; + font-weight: 700; + font-size: 15px; + font-family: 'Fredoka One', cursive; +} +.eta--now { background: #fee2e2; color: #b91c1c; border-color: #fca5a5; } +.eta--soon { background: #fef9c3; color: #92400e; border-color: #fde68a; } +.eta--later { background: #fff7ed; color: #9a3412; border-color: #fed7aa; } +.eta--safe { background: #dcfce7; color: #15803d; border-color: #86efac; } + +.spot-panel__eta-icon { font-size: 20px; line-height: 1; flex-shrink: 0; } +.spot-panel__eta-label { font-family: 'Fredoka', sans-serif; font-weight: 600; } + /* Trend */ .spot-panel__trend { display: flex; diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx index 809a2b1..b4206a0 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.jsx +++ b/frontend/src/components/SpotPanel/SpotPanel.jsx @@ -12,6 +12,15 @@ const TREND_CONFIG = { decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' }, }; +function etaLabel(etaHours) { + if (etaHours === 0) return { icon: '🚨', label: 'Impact en cours', cls: 'eta--now' }; + if (etaHours === 6) return { icon: '⚠️', label: 'Impact prévu dans ~6h', cls: 'eta--soon' }; + if (etaHours === 12) return { icon: '⚠️', label: 'Impact prévu dans ~12h', cls: 'eta--soon' }; + if (etaHours === 24) return { icon: '🕐', label: 'Impact prévu dans ~24h', cls: 'eta--later' }; + if (etaHours === 48) return { icon: '🕐', label: 'Impact prévu dans ~48h', cls: 'eta--later' }; + return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' }; +} + const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' }; export default function SpotPanel({ spot, activeStep, onClose }) { @@ -72,6 +81,16 @@ export default function SpotPanel({ spot, activeStep, onClose }) { ?

Aucune donnée pour cet horizon.

: ( <> + {activeStep === 'now' && (() => { + const eta = etaLabel(displayScore.etaHours ?? undefined); + return ( +
+ {eta.icon} + {eta.label} +
+ ); + })()} +
{LEVEL_LABEL[lvl] ?? lvl}