feat: phase A — backup B2, healthcheck /api/status, vector tiles Tippecanoe
- A1: scripts/backup-postgres.sh — pg_dump quotidien compressé → Backblaze B2 (rclone), rétention 30j - A2: StatusController retourne HTTP 503 + healthy/alertReason si dernière observation > 6h - A4: Dockerfile installe tippecanoe, GenerateTilesCommand génère 5 tilesets (obs + h6/12/24/48), Caddyfile sert /tiles/* sans fallback SPA, SargassesMap.jsx passe en sources vector tiles statiques - chore: backend/public/assets/ ajouté au .gitignore (build artifacts) - chore: setup vitest frontend + ImpactScoreServiceTest (session précédente) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
|||||||
backend/var/
|
backend/var/
|
||||||
backend/vendor/
|
backend/vendor/
|
||||||
backend/public/bundles/
|
backend/public/bundles/
|
||||||
|
backend/public/assets/
|
||||||
backend/.env.local
|
backend/.env.local
|
||||||
backend/.env.test.local
|
backend/.env.test.local
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
File diff suppressed because one or more lines are too long
151
backend/src/Command/GenerateTilesCommand.php
Normal file
151
backend/src/Command/GenerateTilesCommand.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:generate-tiles',
|
||||||
|
description: 'Génère les vector tiles (Tippecanoe) pour observations et prédictions',
|
||||||
|
)]
|
||||||
|
class GenerateTilesCommand extends Command
|
||||||
|
{
|
||||||
|
private const TILE_HORIZONS = [6, 12, 24, 48];
|
||||||
|
private const MIN_ZOOM = 5;
|
||||||
|
private const MAX_ZOOM = 12;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$io->title('Génération vector tiles');
|
||||||
|
|
||||||
|
$tilesDir = $this->projectDir . '/public/tiles';
|
||||||
|
|
||||||
|
// Observations
|
||||||
|
$io->section('Observations');
|
||||||
|
$geojson = $this->queryObservationsGeoJSON();
|
||||||
|
if ($geojson !== null) {
|
||||||
|
$this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io);
|
||||||
|
} else {
|
||||||
|
$io->warning('Aucune observation récente — tiles non générées.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prévisions par horizon
|
||||||
|
foreach (self::TILE_HORIZONS as $horizon) {
|
||||||
|
$io->section("Prévisions H+{$horizon}");
|
||||||
|
$geojson = $this->queryForecastsGeoJSON($horizon);
|
||||||
|
if ($geojson !== null) {
|
||||||
|
$this->runTippecanoe($geojson, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io);
|
||||||
|
} else {
|
||||||
|
$io->warning("Aucune prévision H+{$horizon} — tiles non générées.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success('Génération terminée.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queryObservationsGeoJSON(): ?string
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
"SELECT ST_AsGeoJSON(geometry) AS geometry, afai_mean AS afai
|
||||||
|
FROM sargassum_observation
|
||||||
|
WHERE detected_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND afai_mean > 0
|
||||||
|
ORDER BY detected_at DESC"
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->buildFeatureCollection($rows, [
|
||||||
|
'afai' => static fn($r) => (float) $r['afai'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queryForecastsGeoJSON(int $horizon): ?string
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
"SELECT ST_AsGeoJSON(geometry) AS geometry, confidence
|
||||||
|
FROM sargassum_forecast
|
||||||
|
WHERE time_horizon = :horizon
|
||||||
|
AND computed_at >= NOW() - INTERVAL '30 days'
|
||||||
|
ORDER BY computed_at DESC",
|
||||||
|
['horizon' => $horizon]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->buildFeatureCollection($rows, [
|
||||||
|
'confidence' => static fn($r) => (float) $r['confidence'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array<string, mixed>> $rows
|
||||||
|
* @param array<string, callable(array<string, mixed>): mixed> $props
|
||||||
|
*/
|
||||||
|
private function buildFeatureCollection(array $rows, array $props): ?string
|
||||||
|
{
|
||||||
|
if (empty($rows)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$features = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$properties = [];
|
||||||
|
foreach ($props as $key => $fn) {
|
||||||
|
$properties[$key] = $fn($row);
|
||||||
|
}
|
||||||
|
$features[] = [
|
||||||
|
'type' => 'Feature',
|
||||||
|
'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR),
|
||||||
|
'properties' => $properties,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(
|
||||||
|
['type' => 'FeatureCollection', 'features' => $features],
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void
|
||||||
|
{
|
||||||
|
$tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson';
|
||||||
|
file_put_contents($tmpFile, $geojson);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cmd = sprintf(
|
||||||
|
'tippecanoe -e %s --force --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1',
|
||||||
|
escapeshellarg($outputDir),
|
||||||
|
escapeshellarg($layer),
|
||||||
|
self::MIN_ZOOM,
|
||||||
|
self::MAX_ZOOM,
|
||||||
|
escapeshellarg($tmpFile)
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($cmd, $cmdOutput, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
$errorMsg = implode("\n", $cmdOutput);
|
||||||
|
$this->logger->error('tippecanoe failed', ['output' => $errorMsg, 'dir' => $outputDir]);
|
||||||
|
$io->error("Tippecanoe a échoué : {$errorMsg}");
|
||||||
|
} else {
|
||||||
|
$io->success("→ {$outputDir}");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
@unlink($tmpFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,27 @@ class StatusController extends AbstractController
|
|||||||
ORDER BY requested_at DESC LIMIT 1"
|
ORDER BY requested_at DESC LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Healthcheck : données fraîches si dernière observation < 6h
|
||||||
|
$healthy = false;
|
||||||
|
$alertReason = null;
|
||||||
|
|
||||||
|
if ($obs !== false && $obs['detected_at'] !== null) {
|
||||||
|
$detectedAt = new \DateTimeImmutable($obs['detected_at'], new \DateTimeZone('UTC'));
|
||||||
|
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||||
|
$ageSeconds = $now->getTimestamp() - $detectedAt->getTimestamp();
|
||||||
|
|
||||||
|
if ($ageSeconds <= 6 * 3600) {
|
||||||
|
$healthy = true;
|
||||||
|
} else {
|
||||||
|
$alertReason = 'stale_data';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$alertReason = 'no_data';
|
||||||
|
}
|
||||||
|
|
||||||
return $this->json([
|
return $this->json([
|
||||||
|
'healthy' => $healthy,
|
||||||
|
'alertReason' => $alertReason,
|
||||||
'lastObservation' => $obs !== false ? [
|
'lastObservation' => $obs !== false ? [
|
||||||
'detectedAt' => $obs['detected_at'],
|
'detectedAt' => $obs['detected_at'],
|
||||||
'source' => $obs['source'],
|
'source' => $obs['source'],
|
||||||
@@ -43,6 +63,6 @@ class StatusController extends AbstractController
|
|||||||
'finishedAt' => $job['processed_at'],
|
'finishedAt' => $job['processed_at'],
|
||||||
'retryCount' => (int) $job['retry_count'],
|
'retryCount' => (int) $job['retry_count'],
|
||||||
] : null,
|
] : null,
|
||||||
]);
|
], $healthy ? 200 : 503);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
backend/tests/Unit/Service/ImpactScoreServiceTest.php
Normal file
71
backend/tests/Unit/Service/ImpactScoreServiceTest.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\Forecast\ImpactScoreService;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use App\Service\Push\PushNotificationService;
|
||||||
|
|
||||||
|
class ImpactScoreServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private ImpactScoreService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createStub(Connection::class);
|
||||||
|
$em = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$cache = new ArrayAdapter();
|
||||||
|
$push = $this->createStub(PushNotificationService::class);
|
||||||
|
|
||||||
|
$this->service = new ImpactScoreService(
|
||||||
|
$connection,
|
||||||
|
$em,
|
||||||
|
$cache,
|
||||||
|
new NullLogger(),
|
||||||
|
$push,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scoreToLevel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('scoreLevelProvider')]
|
||||||
|
public function testScoreToLevel(int $score, string $expected): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod(ImpactScoreService::class, 'scoreToLevel');
|
||||||
|
$result = $method->invoke($this->service, $score);
|
||||||
|
|
||||||
|
self::assertSame($expected, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{int, string}> */
|
||||||
|
public static function scoreLevelProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'score 0 → low' => [0, 'low'],
|
||||||
|
'score 30 → low' => [30, 'low'],
|
||||||
|
'score 31 → medium' => [31, 'medium'],
|
||||||
|
'score 70 → medium' => [70, 'medium'],
|
||||||
|
'score 71 → high' => [71, 'high'],
|
||||||
|
'score 100 → high' => [100, 'high'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── emptyScore ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEmptyScoreReturnsCorrectStructure(): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod(ImpactScoreService::class, 'emptyScore');
|
||||||
|
$result = $method->invoke($this->service);
|
||||||
|
|
||||||
|
self::assertSame(0, $result['score']);
|
||||||
|
self::assertSame('low', $result['level']);
|
||||||
|
self::assertNull($result['distance']);
|
||||||
|
self::assertSame(0.0, $result['density']);
|
||||||
|
self::assertSame('stable', $result['trend']);
|
||||||
|
self::assertNull($result['etaHours']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@
|
|||||||
php_server
|
php_server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Vector tiles (.pbf) — pas de fallback SPA, retourner 404 si absent
|
||||||
|
handle /tiles/* {
|
||||||
|
header Content-Type "application/x-protobuf"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
# SPA React : try static files first, fallback to index.html
|
# SPA React : try static files first, fallback to index.html
|
||||||
handle {
|
handle {
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM dunglas/frankenphp:latest-php8.3
|
FROM dunglas/frankenphp:latest-php8.3
|
||||||
|
|
||||||
# Dépendances système (GDAL pour vectorisation satellite)
|
# Dépendances système (GDAL pour vectorisation satellite, Tippecanoe pour vector tiles)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
@@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
git \
|
git \
|
||||||
gdal-bin \
|
gdal-bin \
|
||||||
python3-gdal \
|
python3-gdal \
|
||||||
|
tippecanoe \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Extensions PHP
|
# Extensions PHP
|
||||||
|
|||||||
@@ -147,3 +147,45 @@
|
|||||||
- [ ] Logs structurés (Monolog JSON handler)
|
- [ ] Logs structurés (Monolog JSON handler)
|
||||||
- [ ] Configuration Cloudflare free tier (cache tiles statiques)
|
- [ ] Configuration Cloudflare free tier (cache tiles statiques)
|
||||||
- [ ] Tests fonctionnels pipeline (ingestion → score)
|
- [ ] Tests fonctionnels pipeline (ingestion → score)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Blindage opérationnel *(priorité absolue)*
|
||||||
|
|
||||||
|
> Pré-requis à tout le reste. Un crash VPS sans backup = perte totale des données.
|
||||||
|
|
||||||
|
- [ ] **A1** Backup PostgreSQL automatisé — `pg_dump` quotidien compressé vers stockage externe (Backblaze B2), rétention 30 jours
|
||||||
|
- [ ] **A2** Monitoring pipeline — healthcheck externe (UptimeRobot) sur `/api/status`, alerte email si ingestion absente > 6h
|
||||||
|
- [ ] **A3** CI/CD via Gitea Actions — `phpstan + phpunit + vitest` bloquants sur chaque push ; deploy déclenché par action SSH uniquement si CI ✅ (Gitea installé sur VPS, runner à configurer)
|
||||||
|
- [ ] **A4** Vector tiles Tippecanoe — génération `.pbf` après chaque ingestion, served statiquement par Caddy ; remplacement des sources GeoJSON par sources `vector` côté frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Crédibilité produit
|
||||||
|
|
||||||
|
> Transforme le prototype en produit présentable à un partenaire.
|
||||||
|
|
||||||
|
- [ ] **B1** Analytics — Plausible (self-hosted sur VPS) ou Umami ; métriques sessions, pages vues, îles consultées
|
||||||
|
- [ ] **B2** Onboarding — modale premier lancement : légende polygones orange/violet, explication score, CTA "Sélectionnez un point côtier"
|
||||||
|
- [ ] **B3** Légende interactive carte — compléter le composant `Legend` existant (observations vs prédictions, niveaux de risque)
|
||||||
|
- [ ] **B4** CSP header — Content-Security-Policy adapté MapLibre GL JS + Web Workers + blob URLs
|
||||||
|
- [ ] **B5** HSTS — `Strict-Transport-Security` dans le Caddyfile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — Valeur ajoutée produit
|
||||||
|
|
||||||
|
- [ ] **C1** Vue historique — date picker sur le slider temporel, chargement des observations/scores passés (données déjà en base)
|
||||||
|
- [ ] **C2** Tests d'intégration pipeline — scénario `ingestion GeoJSON synthétique → dérive → score` ; couvre le type de bug ST_Distance/detected_at
|
||||||
|
- [ ] **C3** TypeScript frontend — activation progressive sur les nouveaux fichiers, typage des interfaces API (score, horizons, patches) en priorité
|
||||||
|
- [ ] **C4** Refactoring `SargassesMap.jsx` — découpage du composant monolithique (320 lignes) en sous-composants testables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D — Expansion & B2B
|
||||||
|
|
||||||
|
> À démarrer seulement après A entièrement livré et B partiellement livré.
|
||||||
|
|
||||||
|
- [ ] **D1** Documenter le coût marginal d'ajout d'une zone — temps + quota Sentinel Hub + coût stockage tiles, pour chiffrer l'expansion (Yucatán, Brésil, Açores…)
|
||||||
|
- [ ] **D2** API publique documentée avec clé — endpoint `/api/v1/score` + quota + documentation OpenAPI ; ouvre la porte au B2B (offices de tourisme, collectivités, assureurs maritimes)
|
||||||
|
- [ ] **D3** Expansion géographique — première zone hors Caraïbes (candidat : côtes du Yucatán, Mexico)
|
||||||
|
|||||||
1127
frontend/package-lock.json
generated
1127
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "^5.21.1",
|
"maplibre-gl": "^5.21.1",
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -24,6 +28,8 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"vite": "^8.0.1"
|
"jsdom": "^29.0.2",
|
||||||
|
"vite": "^8.0.1",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,18 +66,16 @@ function cartoonizeStyle(style) {
|
|||||||
return { ...style, layers };
|
return { ...style, layers };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre : affiche uniquement les features avec sargasses (afai > 0),
|
|
||||||
// ou si la propriété est absente (données legacy sans afaiIndex).
|
|
||||||
const OBS_FILTER = ['any', ['!', ['has', 'afai']], ['>', ['get', 'afai'], 0]];
|
|
||||||
|
|
||||||
// ── Layer style factories ──────────────────────────────────────────────────
|
// ── Layer style factories ──────────────────────────────────────────────────
|
||||||
const makeLayerStyles = (isPrediction, opacity) => ({
|
const makeLayerStyles = (isPrediction, opacity) => {
|
||||||
|
const sourceKey = isPrediction ? 'forecasts' : 'observations';
|
||||||
|
return {
|
||||||
fill: {
|
fill: {
|
||||||
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
|
id: isPrediction ? 'forecasts-fill' : 'observations-fill',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: isPrediction ? 'forecasts' : 'observations',
|
source: sourceKey,
|
||||||
|
'source-layer': sourceKey,
|
||||||
paint: {
|
paint: {
|
||||||
// Prévisions : même couleur sargasses que l'observation, juste atténuée
|
|
||||||
'fill-color': '#f4a020',
|
'fill-color': '#f4a020',
|
||||||
'fill-opacity': (isPrediction ? 0.20 : 0.50) * opacity,
|
'fill-opacity': (isPrediction ? 0.20 : 0.50) * opacity,
|
||||||
'fill-opacity-transition': { duration: 350 },
|
'fill-opacity-transition': { duration: 350 },
|
||||||
@@ -86,7 +84,8 @@ const makeLayerStyles = (isPrediction, opacity) => ({
|
|||||||
line: {
|
line: {
|
||||||
id: isPrediction ? 'forecasts-line' : 'observations-line',
|
id: isPrediction ? 'forecasts-line' : 'observations-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: isPrediction ? 'forecasts' : 'observations',
|
source: sourceKey,
|
||||||
|
'source-layer': sourceKey,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#c47a10',
|
'line-color': '#c47a10',
|
||||||
'line-width': 2.5,
|
'line-width': 2.5,
|
||||||
@@ -95,7 +94,8 @@ const makeLayerStyles = (isPrediction, opacity) => ({
|
|||||||
...(isPrediction ? { 'line-dasharray': [6, 4] } : {}),
|
...(isPrediction ? { 'line-dasharray': [6, 4] } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ── SVG Markers ───────────────────────────────────────────────────────────
|
// ── SVG Markers ───────────────────────────────────────────────────────────
|
||||||
function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
|
function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
|
||||||
@@ -148,19 +148,17 @@ function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const horizonToInt = (h) => parseInt(h.replace('h', ''), 10);
|
|
||||||
|
|
||||||
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
|
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const [spots, setSpots] = useState([]);
|
const [spots, setSpots] = useState([]);
|
||||||
const [spotScores, setSpotScores] = useState({});
|
const [spotScores, setSpotScores] = useState({});
|
||||||
const [obsGeoJSON, setObsGeoJSON] = useState(null);
|
|
||||||
const [foreGeoJSON, setForeGeoJSON] = useState(null);
|
|
||||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||||
const [layerOpacity, setLayerOpacity] = useState(1);
|
const [layerOpacity, setLayerOpacity] = useState(1);
|
||||||
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
|
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
|
||||||
const [dataDate, setDataDate] = useState(null);
|
const [dataDate, setDataDate] = useState(null);
|
||||||
|
|
||||||
|
const tilesOrigin = window.location.origin;
|
||||||
|
|
||||||
// Fraîcheur des données
|
// Fraîcheur des données
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.status.get()
|
api.status.get()
|
||||||
@@ -215,47 +213,11 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
|
|||||||
mapRef.current?.getMap()?.flyTo({ center: flyToTarget.center, zoom: flyToTarget.zoom, speed: 1.4 });
|
mapRef.current?.getMap()?.flyTo({ center: flyToTarget.center, zoom: flyToTarget.zoom, speed: 1.4 });
|
||||||
}, [flyToTarget]);
|
}, [flyToTarget]);
|
||||||
|
|
||||||
const loadLayers = useCallback(() => {
|
|
||||||
const map = mapRef.current?.getMap();
|
|
||||||
if (!map) return;
|
|
||||||
const b = map.getBounds();
|
|
||||||
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map(v => v.toFixed(4)).join(',');
|
|
||||||
|
|
||||||
if (activeStep === 'now') {
|
|
||||||
api.observations.list(bbox)
|
|
||||||
.then(data => setObsGeoJSON(toFeatureCollection(data.items)))
|
|
||||||
.catch(console.error);
|
|
||||||
setForeGeoJSON(null);
|
|
||||||
} else {
|
|
||||||
api.forecasts.list(bbox, horizonToInt(activeStep))
|
|
||||||
.then(data => { setForeGeoJSON(toFeatureCollection(data.items)); setObsGeoJSON(null); })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}, [activeStep]);
|
|
||||||
|
|
||||||
useEffect(() => { loadLayers(); }, [loadLayers]);
|
|
||||||
|
|
||||||
const toFeatureCollection = (items = []) => {
|
|
||||||
const features = (items ?? []).filter(i => i.geometry)
|
|
||||||
.map(i => ({
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: i.geometry,
|
|
||||||
// Ne pas injecter afai si absent : MapLibre traite null comme "has=true"
|
|
||||||
// ce qui ferait échouer le filtre > 0 et masquerait le polygone.
|
|
||||||
properties: typeof i.afaiIndex === 'number' ? { afai: i.afaiIndex } : {},
|
|
||||||
}));
|
|
||||||
return features.length ? { type: 'FeatureCollection', features } : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCoords = (spot) =>
|
const getCoords = (spot) =>
|
||||||
spot.longitude != null && spot.latitude != null
|
spot.longitude != null && spot.latitude != null
|
||||||
? { lng: spot.longitude, lat: spot.latitude }
|
? { lng: spot.longitude, lat: spot.latitude }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleMapLoad = useCallback(() => {
|
|
||||||
loadLayers();
|
|
||||||
}, [loadLayers]);
|
|
||||||
|
|
||||||
const OBS_STYLES = makeLayerStyles(false, layerOpacity);
|
const OBS_STYLES = makeLayerStyles(false, layerOpacity);
|
||||||
const FORE_STYLES = makeLayerStyles(true, layerOpacity);
|
const FORE_STYLES = makeLayerStyles(true, layerOpacity);
|
||||||
|
|
||||||
@@ -264,8 +226,6 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
|
|||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
{...viewState}
|
{...viewState}
|
||||||
onMove={e => setViewState(e.viewState)}
|
onMove={e => setViewState(e.viewState)}
|
||||||
onMoveEnd={loadLayers}
|
|
||||||
onLoad={handleMapLoad}
|
|
||||||
onClick={() => onSpotSelect(null)}
|
onClick={() => onSpotSelect(null)}
|
||||||
mapStyle={mapStyle}
|
mapStyle={mapStyle}
|
||||||
style={{ width: '100%', height: '100vh' }}
|
style={{ width: '100%', height: '100vh' }}
|
||||||
@@ -280,15 +240,28 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{obsGeoJSON && (
|
{activeStep === 'now' && (
|
||||||
<Source id="observations" type="geojson" data={obsGeoJSON}>
|
<Source
|
||||||
|
id="observations"
|
||||||
|
type="vector"
|
||||||
|
tiles={[`${tilesOrigin}/tiles/observations/{z}/{x}/{y}.pbf`]}
|
||||||
|
minzoom={5}
|
||||||
|
maxzoom={12}
|
||||||
|
>
|
||||||
<Layer {...OBS_STYLES.fill} />
|
<Layer {...OBS_STYLES.fill} />
|
||||||
<Layer {...OBS_STYLES.line} />
|
<Layer {...OBS_STYLES.line} />
|
||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{foreGeoJSON && (
|
{activeStep !== 'now' && (
|
||||||
<Source id="forecasts" type="geojson" data={foreGeoJSON}>
|
<Source
|
||||||
|
key={activeStep}
|
||||||
|
id="forecasts"
|
||||||
|
type="vector"
|
||||||
|
tiles={[`${tilesOrigin}/tiles/forecasts/${activeStep}/{z}/{x}/{y}.pbf`]}
|
||||||
|
minzoom={5}
|
||||||
|
maxzoom={12}
|
||||||
|
>
|
||||||
<Layer {...FORE_STYLES.fill} />
|
<Layer {...FORE_STYLES.fill} />
|
||||||
<Layer {...FORE_STYLES.line} />
|
<Layer {...FORE_STYLES.line} />
|
||||||
</Source>
|
</Source>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import usePushSubscription from '../../hooks/usePushSubscription';
|
import usePushSubscription from '../../hooks/usePushSubscription';
|
||||||
|
import { scoreLabel, computeEta, etaLabel, ETA_BUFFER_KM } from '../../utils/scoring';
|
||||||
import './SpotPanel.css';
|
import './SpotPanel.css';
|
||||||
|
|
||||||
function scoreLabel(value, level, distance) {
|
|
||||||
if (level === 'high') return 'Impact';
|
|
||||||
if (level === 'medium') return 'Risque';
|
|
||||||
if (value >= 15 && distance != null && distance < 100) return 'Veille';
|
|
||||||
return 'OK';
|
|
||||||
}
|
|
||||||
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
|
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
|
||||||
|
|
||||||
const TREND_CONFIG = {
|
const TREND_CONFIG = {
|
||||||
@@ -17,27 +11,6 @@ const TREND_CONFIG = {
|
|||||||
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
|
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ETA_BUFFER_KM = 5;
|
|
||||||
|
|
||||||
function computeEta(score, horizons) {
|
|
||||||
if (score?.distance != null && score.distance < ETA_BUFFER_KM) return 0;
|
|
||||||
for (const h of [6, 12, 24, 48]) {
|
|
||||||
const horizon = horizons?.[`h${h}`];
|
|
||||||
if (horizon?.distanceKm != null && horizon.distanceKm < ETA_BUFFER_KM) return h;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function etaLabel(etaHours, score, distance) {
|
|
||||||
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' };
|
|
||||||
if ((score ?? 0) >= 15 && distance != null && distance < 100)
|
|
||||||
return { icon: '👁', label: 'Sargasses en surveillance', cls: 'eta--watch' };
|
|
||||||
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
|
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
|
||||||
|
|
||||||
|
|||||||
1
frontend/src/test/setup.js
Normal file
1
frontend/src/test/setup.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
51
frontend/src/utils/scoring.js
Normal file
51
frontend/src/utils/scoring.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Fonctions pures de scoring — extraites de SpotPanel pour être testables
|
||||||
|
* indépendamment du rendu React.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ETA_BUFFER_KM = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellé dominant de l'indice de risque.
|
||||||
|
* @param {number} value Score brut 0-100
|
||||||
|
* @param {string} level 'low' | 'medium' | 'high'
|
||||||
|
* @param {number|null} distance Distance km au patch le plus proche
|
||||||
|
*/
|
||||||
|
export function scoreLabel(value, level, distance) {
|
||||||
|
if (level === 'high') return 'Impact';
|
||||||
|
if (level === 'medium') return 'Risque';
|
||||||
|
if (value >= 15 && distance != null && distance < 100) return 'Veille';
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'horizon (h) auquel un impact est prévu.
|
||||||
|
* Retourne 0 si impact en cours, null si aucun prévu dans 48h.
|
||||||
|
* @param {{ distance?: number|null }|null} score
|
||||||
|
* @param {Record<string, { distanceKm?: number|null }>|null} horizons
|
||||||
|
*/
|
||||||
|
export function computeEta(score, horizons) {
|
||||||
|
if (score?.distance != null && score.distance < ETA_BUFFER_KM) return 0;
|
||||||
|
for (const h of [6, 12, 24, 48]) {
|
||||||
|
const horizon = horizons?.[`h${h}`];
|
||||||
|
if (horizon?.distanceKm != null && horizon.distanceKm < ETA_BUFFER_KM) return h;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'objet label/icône/classe CSS pour l'affichage ETA.
|
||||||
|
* @param {number|null} etaHours
|
||||||
|
* @param {number|null|undefined} score
|
||||||
|
* @param {number|null|undefined} distance
|
||||||
|
*/
|
||||||
|
export function etaLabel(etaHours, score, distance) {
|
||||||
|
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' };
|
||||||
|
if ((score ?? 0) >= 15 && distance != null && distance < 100)
|
||||||
|
return { icon: '👁', label: 'Sargasses en surveillance', cls: 'eta--watch' };
|
||||||
|
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
|
||||||
|
}
|
||||||
121
frontend/src/utils/scoring.test.js
Normal file
121
frontend/src/utils/scoring.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { scoreLabel, computeEta, etaLabel, ETA_BUFFER_KM } from './scoring';
|
||||||
|
|
||||||
|
// ─── scoreLabel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('scoreLabel', () => {
|
||||||
|
it('retourne "Impact" pour level high, quel que soit le score', () => {
|
||||||
|
expect(scoreLabel(10, 'high', null)).toBe('Impact');
|
||||||
|
expect(scoreLabel(0, 'high', 200)).toBe('Impact');
|
||||||
|
expect(scoreLabel(100,'high', 0 )).toBe('Impact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne "Risque" pour level medium', () => {
|
||||||
|
expect(scoreLabel(50, 'medium', 50)).toBe('Risque');
|
||||||
|
expect(scoreLabel(31, 'medium', null)).toBe('Risque');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne "Veille" si score ≥ 15 ET distance < 100km (level low)', () => {
|
||||||
|
expect(scoreLabel(15, 'low', 99)).toBe('Veille');
|
||||||
|
expect(scoreLabel(20, 'low', 0 )).toBe('Veille');
|
||||||
|
expect(scoreLabel(15, 'low', 100)).toBe('OK'); // distance = seuil exact → non inclus
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne "OK" si score < 15 même avec distance proche', () => {
|
||||||
|
expect(scoreLabel(14, 'low', 50)).toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne "OK" si distance null même avec score ≥ 15', () => {
|
||||||
|
expect(scoreLabel(20, 'low', null)).toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne "OK" si distance ≥ 100km', () => {
|
||||||
|
expect(scoreLabel(50, 'low', 100)).toBe('OK');
|
||||||
|
expect(scoreLabel(80, 'low', 150)).toBe('OK');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── computeEta ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('computeEta', () => {
|
||||||
|
it('retourne 0 si les sargasses sont déjà à moins de ETA_BUFFER_KM', () => {
|
||||||
|
expect(computeEta({ distance: 0 }, null)).toBe(0);
|
||||||
|
expect(computeEta({ distance: ETA_BUFFER_KM - 0.1 }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne null si aucun horizon < ETA_BUFFER_KM', () => {
|
||||||
|
const horizons = {
|
||||||
|
h6: { distanceKm: 50 },
|
||||||
|
h12: { distanceKm: 40 },
|
||||||
|
h24: { distanceKm: 30 },
|
||||||
|
h48: { distanceKm: 20 },
|
||||||
|
};
|
||||||
|
expect(computeEta({ distance: 100 }, horizons)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne le premier horizon qui passe sous ETA_BUFFER_KM', () => {
|
||||||
|
const horizons = {
|
||||||
|
h6: { distanceKm: 50 },
|
||||||
|
h12: { distanceKm: 50 },
|
||||||
|
h24: { distanceKm: 3 }, // < 5km
|
||||||
|
h48: { distanceKm: 0 },
|
||||||
|
};
|
||||||
|
expect(computeEta({ distance: 100 }, horizons)).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne h6 si dès le premier horizon', () => {
|
||||||
|
const horizons = { h6: { distanceKm: 2 } };
|
||||||
|
expect(computeEta({ distance: 100 }, horizons)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gère score null / horizons null sans erreur', () => {
|
||||||
|
expect(computeEta(null, null)).toBeNull();
|
||||||
|
expect(computeEta({ distance: null }, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne 0 si distance exactement ETA_BUFFER_KM - epsilon', () => {
|
||||||
|
expect(computeEta({ distance: ETA_BUFFER_KM - 0.001 }, null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne null si distance exactement ETA_BUFFER_KM', () => {
|
||||||
|
// distance >= ETA_BUFFER_KM → pas d'impact en cours, on regarde les horizons
|
||||||
|
const horizons = { h6: { distanceKm: 10 }, h12: { distanceKm: 10 }, h24: { distanceKm: 10 }, h48: { distanceKm: 10 } };
|
||||||
|
expect(computeEta({ distance: ETA_BUFFER_KM }, horizons)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── etaLabel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('etaLabel', () => {
|
||||||
|
it('retourne eta--now pour etaHours=0', () => {
|
||||||
|
const r = etaLabel(0, null, null);
|
||||||
|
expect(r.cls).toBe('eta--now');
|
||||||
|
expect(r.label).toBe('Impact en cours');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne eta--soon pour etaHours=6 et 12', () => {
|
||||||
|
expect(etaLabel(6, null, null).cls).toBe('eta--soon');
|
||||||
|
expect(etaLabel(12, null, null).cls).toBe('eta--soon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne eta--later pour etaHours=24 et 48', () => {
|
||||||
|
expect(etaLabel(24, null, null).cls).toBe('eta--later');
|
||||||
|
expect(etaLabel(48, null, null).cls).toBe('eta--later');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne eta--watch si score ≥ 15 ET distance < 100 ET etaHours null', () => {
|
||||||
|
expect(etaLabel(null, 15, 50).cls).toBe('eta--watch');
|
||||||
|
expect(etaLabel(null, 20, 99).cls).toBe('eta--watch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne eta--safe si pas d\'impact (fallback)', () => {
|
||||||
|
expect(etaLabel(null, 0, 200).cls).toBe('eta--safe');
|
||||||
|
expect(etaLabel(null, 14, 50 ).cls).toBe('eta--safe'); // score < 15
|
||||||
|
expect(etaLabel(null, 20, 100).cls).toBe('eta--safe'); // distance = 100 → non inclus
|
||||||
|
expect(etaLabel(null, 20, null).cls).toBe('eta--safe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne eta--safe si score null (considéré comme 0)', () => {
|
||||||
|
expect(etaLabel(null, null, 50).cls).toBe('eta--safe');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,11 @@ const cleanAssetsPlugin = () => ({
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), cleanAssetsPlugin()],
|
plugins: [react(), cleanAssetsPlugin()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: './src/test/setup.js',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user