From c89560aea2873b26a165346fead95aef6e1a20c1 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Fri, 3 Apr 2026 23:36:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20suivi=20par=20patch=20=E2=80=94=20d?= =?UTF-8?q?=C3=A9composition=20MULTIPOLYGON=20avec=20distance=20et=20surfa?= =?UTF-8?q?ce=20par=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ST_Dump sur la dernière observation → 5 patches les plus proches dans 200km, exposés dans /spots/{id}/score. SpotPanel affiche le nombre de patches et la distance+surface de chacun sous forme de barres (échelle 0–100km). Co-Authored-By: Claude Sonnet 4.6 --- .../src/Controller/SpotScoreController.php | 40 +++++++++++++++ .../src/components/SpotPanel/SpotPanel.css | 50 +++++++++++++++++++ .../src/components/SpotPanel/SpotPanel.jsx | 32 ++++++++++++ 3 files changed, 122 insertions(+) diff --git a/backend/src/Controller/SpotScoreController.php b/backend/src/Controller/SpotScoreController.php index 2106b54..70f15ae 100644 --- a/backend/src/Controller/SpotScoreController.php +++ b/backend/src/Controller/SpotScoreController.php @@ -57,6 +57,7 @@ class SpotScoreController extends AbstractController 'type' => $spot->getType(), 'region' => $spot->getRegion(), 'score' => $current, + 'patches' => $this->loadPatches($id), 'horizons' => $this->loadHorizonScores($id, $currentDistanceKm), ]); } @@ -207,6 +208,45 @@ class SpotScoreController extends AbstractController ]; } + /** + * Décompose la dernière observation en patches individuels (ST_Dump) + * et retourne les 5 plus proches du spot dans un rayon de 200 km. + * + * @return array|null + */ + private function loadPatches(string $spotId): ?array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT + ROUND(CAST(ST_Area(dump.geom::geography) / 1000000 AS numeric), 2) AS area_km2, + ROUND(CAST(ST_Distance(cp.geometry::geography, dump.geom::geography) / 1000 AS numeric), 2) AS distance_km + FROM coastal_point cp + CROSS JOIN LATERAL ( + SELECT (ST_Dump(o.geometry)).geom AS geom + FROM sargassum_observation o + WHERE o.id = ( + SELECT o2.id FROM sargassum_observation o2 + INNER JOIN sargassum_forecast ff ON ff.source_observation_id = o2.id + ORDER BY o2.detected_at DESC LIMIT 1 + ) + ) dump + WHERE cp.id = :spotId + AND ST_Distance(cp.geometry::geography, dump.geom::geography) < 200000 + ORDER BY distance_km ASC + LIMIT 5', + ['spotId' => $spotId] + ); + + if (empty($rows)) { + return null; + } + + return array_map( + fn(array $r) => ['areaKm2' => (float) $r['area_km2'], 'distanceKm' => (float) $r['distance_km']], + $rows + ); + } + private function scoreToLevel(int $score): string { return match (true) { diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css index d94ca99..193dcd7 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.css +++ b/frontend/src/components/SpotPanel/SpotPanel.css @@ -233,6 +233,56 @@ font-weight: 400; } +/* Patch list */ +.patch-list { + border-top: 1px solid rgba(0,0,0,0.08); + padding-top: 10px; + margin-top: 4px; +} + +.patch-list__title { + margin: 0 0 8px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: #999; +} + +.patch-list__row { + display: grid; + grid-template-columns: 52px 1fr 52px; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} + +.patch-list__dist { + font-size: 11px; + font-weight: 600; + color: #1a1a2e; + text-align: right; +} + +.patch-list__track { + height: 5px; + background: rgba(0,0,0,0.08); + border-radius: 3px; + overflow: hidden; +} + +.patch-list__fill { + height: 100%; + background: #c47a10; + border-radius: 3px; + transition: width .5s ease; +} + +.patch-list__area { + font-size: 11px; + color: #888; +} + /* Feedback */ .spot-panel__feedback { border-top: 2px solid #e5e7eb; diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx index f518f82..dc2dc54 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.jsx +++ b/frontend/src/components/SpotPanel/SpotPanel.jsx @@ -140,6 +140,8 @@ export default function SpotPanel({ spot, activeStep, onClose }) { + + {activeStep === 'now' && } ) )} @@ -151,6 +153,36 @@ export default function SpotPanel({ spot, activeStep, onClose }) { ); } +function PatchList({ patches }) { + if (!patches?.length) return null; + + // Échelle visuelle : 100 km = barre pleine + const MAX_KM = 100; + + return ( +
+

+ {patches.length} patch{patches.length > 1 ? 'es' : ''} détecté{patches.length > 1 ? 's' : ''} +

+ {patches.map((p, i) => { + const pct = Math.min((p.distanceKm / MAX_KM) * 100, 100); + const sizeLabel = p.areaKm2 >= 10 + ? `${p.areaKm2} km²` + : `${p.areaKm2} km²`; + return ( +
+ {p.distanceKm} km +
+
+
+ {sizeLabel} +
+ ); + })} +
+ ); +} + const BREAKDOWN_ITEMS = [ { key: 'distance', label: 'Distance', max: 40, icon: '📏' }, { key: 'density', label: 'Densité', max: 30, icon: '🌿' },