feat: différencier visuellement Sentinel-2 (fiable) vs Sentinel-3 (bruit)

S3 OLCI n'a pas de bande SWIR : impossible de filtrer sun glint résiduel
et aérosols sahariens → faux positifs massifs à 300 m de résolution.

- Tuiles : ajout des propriétés source/confidence/coverage_km2
- Carte : styling data-driven par source
    S2 → orange vif (#f4720a), opacité 65 % — signal confirmé
    S3 → or pâle (#d4b830), opacité 20 % — signal incertain
- Légende : entrées distinctes S2 / S3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-14 12:33:11 -04:00
parent 08a4754b26
commit 14d2acc39b
4 changed files with 54 additions and 13 deletions

View File

@@ -63,7 +63,11 @@ class GenerateTilesCommand extends Command
private function queryObservationsGeoJSON(): ?string private function queryObservationsGeoJSON(): ?string
{ {
$rows = $this->connection->fetchAllAssociative( $rows = $this->connection->fetchAllAssociative(
"SELECT ST_AsGeoJSON(geometry) AS geometry, COALESCE(afai_mean, 0) AS afai "SELECT ST_AsGeoJSON(geometry) AS geometry,
COALESCE(afai_mean, 0) AS afai,
source,
confidence,
ROUND(COALESCE(coverage_area, 0)::numeric, 1) AS coverage_km2
FROM sargassum_observation FROM sargassum_observation
WHERE detected_at >= NOW() - INTERVAL '30 days' WHERE detected_at >= NOW() - INTERVAL '30 days'
AND coverage_area > 0 AND coverage_area > 0
@@ -71,7 +75,10 @@ class GenerateTilesCommand extends Command
); );
return $this->buildFeatureCollection($rows, [ return $this->buildFeatureCollection($rows, [
'afai' => static fn($r) => (float) $r['afai'], 'afai' => static fn($r) => (float) $r['afai'],
'source' => static fn($r) => $r['source'], // 'Sentinel-2' | 'Sentinel-3'
'confidence' => static fn($r) => (float) $r['confidence'],
'coverage_km2' => static fn($r) => (float) $r['coverage_km2'],
]); ]);
} }

View File

@@ -39,8 +39,9 @@
border: 2px solid #333; border: 2px solid #333;
flex-shrink: 0; flex-shrink: 0;
} }
.legend__swatch--obs { background: #f4a020; } .legend__swatch--obs-s2 { background: #f4720a; }
.legend__swatch--fore { background: #818cf8; } .legend__swatch--obs-s3 { background: #d4b830; opacity: 0.7; }
.legend__swatch--fore { background: #818cf8; }
.legend__divider { .legend__divider {
height: 2px; height: 2px;

View File

@@ -5,8 +5,12 @@ export default function Legend() {
<aside className="legend" aria-label="Légende"> <aside className="legend" aria-label="Légende">
<p className="legend__title">Légende</p> <p className="legend__title">Légende</p>
<div className="legend__item"> <div className="legend__item">
<span className="legend__swatch legend__swatch--obs" /> <span className="legend__swatch legend__swatch--obs-s2" />
<span>Zone observée</span> <span>Sargasses (S2 confirmé)</span>
</div>
<div className="legend__item">
<span className="legend__swatch legend__swatch--obs-s3" />
<span>Signal incertain (S3)</span>
</div> </div>
<div className="legend__item"> <div className="legend__item">
<span className="legend__swatch legend__swatch--fore" /> <span className="legend__swatch legend__swatch--fore" />

View File

@@ -67,8 +67,39 @@ function cartoonizeStyle(style) {
} }
// ── Layer style factories ────────────────────────────────────────────────── // ── Layer style factories ──────────────────────────────────────────────────
//
// Sentinel-2 (AFAI calibré, 10 m) → orange vif, opacité forte : signal fiable
// Sentinel-3 (MCI 300 m, pas de SWIR) → or pâle, opacité faible : signal incertain
// Prévisions → orange tirets, opacité très réduite
//
const S2_COLOR = '#f4720a'; // orange vif
const S3_COLOR = '#d4b830'; // or/ambre pâle
const FORE_COLOR = '#f4a020';
const makeLayerStyles = (isPrediction, opacity) => { const makeLayerStyles = (isPrediction, opacity) => {
const sourceKey = isPrediction ? 'forecasts' : 'observations'; const sourceKey = isPrediction ? 'forecasts' : 'observations';
const fillColor = isPrediction
? FORE_COLOR
: ['match', ['get', 'source'],
'Sentinel-2', S2_COLOR,
S3_COLOR, // fallback = Sentinel-3
];
const fillOpacity = isPrediction
? 0.20 * opacity
: ['match', ['get', 'source'],
'Sentinel-2', 0.65 * opacity,
0.20 * opacity, // S3 beaucoup plus transparent
];
const lineOpacity = isPrediction
? 0.65 * opacity
: ['match', ['get', 'source'],
'Sentinel-2', 0.85 * opacity,
0.30 * opacity,
];
return { return {
fill: { fill: {
id: isPrediction ? 'forecasts-fill' : 'observations-fill', id: isPrediction ? 'forecasts-fill' : 'observations-fill',
@@ -76,9 +107,8 @@ const makeLayerStyles = (isPrediction, opacity) => {
source: sourceKey, source: sourceKey,
'source-layer': sourceKey, 'source-layer': sourceKey,
paint: { paint: {
'fill-color': '#f4a020', 'fill-color': fillColor,
'fill-opacity': (isPrediction ? 0.20 : 0.50) * opacity, 'fill-opacity': fillOpacity,
'fill-opacity-transition': { duration: 350 },
}, },
}, },
line: { line: {
@@ -87,10 +117,9 @@ const makeLayerStyles = (isPrediction, opacity) => {
source: sourceKey, source: sourceKey,
'source-layer': sourceKey, 'source-layer': sourceKey,
paint: { paint: {
'line-color': '#c47a10', 'line-color': isPrediction ? FORE_COLOR : ['match', ['get', 'source'], 'Sentinel-2', '#c44a05', '#a08010'],
'line-width': 2.5, 'line-width': isPrediction ? 2.5 : ['match', ['get', 'source'], 'Sentinel-2', 2.5, 1.0],
'line-opacity': opacity * (isPrediction ? 0.65 : 0.85), 'line-opacity': lineOpacity,
'line-opacity-transition': { duration: 350 },
...(isPrediction ? { 'line-dasharray': [6, 4] } : {}), ...(isPrediction ? { 'line-dasharray': [6, 4] } : {}),
}, },
}, },