feat: suivi par patch — décomposition MULTIPOLYGON avec distance et surface par patch
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 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ class SpotScoreController extends AbstractController
|
|||||||
'type' => $spot->getType(),
|
'type' => $spot->getType(),
|
||||||
'region' => $spot->getRegion(),
|
'region' => $spot->getRegion(),
|
||||||
'score' => $current,
|
'score' => $current,
|
||||||
|
'patches' => $this->loadPatches($id),
|
||||||
'horizons' => $this->loadHorizonScores($id, $currentDistanceKm),
|
'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<int, array{areaKm2: float, distanceKm: float}>|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
|
private function scoreToLevel(int $score): string
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
|
|||||||
@@ -233,6 +233,56 @@
|
|||||||
font-weight: 400;
|
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 */
|
/* Feedback */
|
||||||
.spot-panel__feedback {
|
.spot-panel__feedback {
|
||||||
border-top: 2px solid #e5e7eb;
|
border-top: 2px solid #e5e7eb;
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<ScoreBreakdown breakdown={displayScore.breakdown} />
|
<ScoreBreakdown breakdown={displayScore.breakdown} />
|
||||||
|
|
||||||
|
{activeStep === 'now' && <PatchList patches={data?.patches} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<div className="patch-list">
|
||||||
|
<p className="patch-list__title">
|
||||||
|
{patches.length} patch{patches.length > 1 ? 'es' : ''} détecté{patches.length > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
{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 (
|
||||||
|
<div key={i} className="patch-list__row">
|
||||||
|
<span className="patch-list__dist">{p.distanceKm} km</span>
|
||||||
|
<div className="patch-list__track">
|
||||||
|
<div className="patch-list__fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="patch-list__area">{sizeLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const BREAKDOWN_ITEMS = [
|
const BREAKDOWN_ITEMS = [
|
||||||
{ key: 'distance', label: 'Distance', max: 40, icon: '📏' },
|
{ key: 'distance', label: 'Distance', max: 40, icon: '📏' },
|
||||||
{ key: 'density', label: 'Densité', max: 30, icon: '🌿' },
|
{ key: 'density', label: 'Densité', max: 30, icon: '🌿' },
|
||||||
|
|||||||
Reference in New Issue
Block a user