add trend UX + PHPStan level 6 clean (0 errors)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 16:06:13 -04:00
parent 997e2d186c
commit 0d323b871b
26 changed files with 471 additions and 78 deletions

View File

@@ -41,7 +41,8 @@
"allow-plugins": { "allow-plugins": {
"php-http/discovery": true, "php-http/discovery": true,
"symfony/flex": true, "symfony/flex": true,
"symfony/runtime": true "symfony/runtime": true,
"phpstan/extension-installer": true
}, },
"bump-after-update": true, "bump-after-update": true,
"sort-packages": true "sort-packages": true
@@ -86,5 +87,11 @@
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "7.4.*"
} }
},
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0"
} }
} }

256
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c960cf2dbc66a8c555b534d43072fb46", "content-hash": "c745217057712f8ff1cb5cc0fc379c1b",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -8943,7 +8943,259 @@
"time": "2022-01-30T20:08:53+00:00" "time": "2022-01-30T20:08:53+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [
{
"name": "phpstan/extension-installer",
"version": "1.4.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/extension-installer.git",
"reference": "85e90b3942d06b2326fba0403ec24fe912372936"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936",
"reference": "85e90b3942d06b2326fba0403ec24fe912372936",
"shasum": ""
},
"require": {
"composer-plugin-api": "^2.0",
"php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.9.0 || ^2.0"
},
"require-dev": {
"composer/composer": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2.0",
"phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0"
},
"type": "composer-plugin",
"extra": {
"class": "PHPStan\\ExtensionInstaller\\Plugin"
},
"autoload": {
"psr-4": {
"PHPStan\\ExtensionInstaller\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Composer plugin for automatic installation of PHPStan extensions",
"keywords": [
"dev",
"static analysis"
],
"support": {
"issues": "https://github.com/phpstan/extension-installer/issues",
"source": "https://github.com/phpstan/extension-installer/tree/1.4.3"
},
"time": "2024-09-04T20:21:43+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.46",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-04-01T09:25:14+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
"version": "2.0.20",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
"reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/72f4f7a02d6c98d9101e8616e0488bc0a785196d",
"reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.34"
},
"conflict": {
"doctrine/collections": "<1.0",
"doctrine/common": "<2.7",
"doctrine/mongodb-odm": "<1.2",
"doctrine/orm": "<2.5",
"doctrine/persistence": "<1.3"
},
"require-dev": {
"cache/array-adapter": "^1.1",
"composer/semver": "^3.3.2",
"cweagans/composer-patches": "^1.7.3",
"doctrine/annotations": "^2.0",
"doctrine/collections": "^1.6 || ^2.1",
"doctrine/common": "^2.7 || ^3.0",
"doctrine/dbal": "^3.3.8",
"doctrine/lexer": "^2.0 || ^3.0",
"doctrine/mongodb-odm": "^2.4.3",
"doctrine/orm": "^2.16.0",
"doctrine/persistence": "^2.2.1 || ^3.4.3",
"gedmo/doctrine-extensions": "^3.8",
"nesbot/carbon": "^2.49",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0.2",
"phpstan/phpstan-phpunit": "^2.0.8",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6.20",
"ramsey/uuid": "^4.2",
"symfony/cache": "^5.4",
"symfony/uid": "^5.4 || ^6.4 || ^7.3"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Doctrine extensions for PHPStan",
"keywords": [
"static analysis"
],
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.20"
},
"time": "2026-03-13T13:44:51+00:00"
},
{
"name": "phpstan/phpstan-symfony",
"version": "2.0.15",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "9b85ab476969b87bbe2253b69e265a9359b2f395"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/9b85ab476969b87bbe2253b69e265a9359b2f395",
"reference": "9b85ab476969b87bbe2253b69e265a9359b2f395",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.13"
},
"conflict": {
"symfony/framework-bundle": "<3.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0.8",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"psr/container": "1.1.2",
"symfony/config": "^5.4 || ^6.1",
"symfony/console": "^5.4 || ^6.1",
"symfony/dependency-injection": "^5.4 || ^6.1",
"symfony/form": "^5.4 || ^6.1",
"symfony/framework-bundle": "^5.4 || ^6.1",
"symfony/http-foundation": "^5.4 || ^6.1",
"symfony/messenger": "^5.4",
"symfony/polyfill-php80": "^1.24",
"symfony/serializer": "^5.4",
"symfony/service-contracts": "^2.2.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lukáš Unger",
"email": "looky.msc@gmail.com",
"homepage": "https://lookyman.net"
}
],
"description": "Symfony Framework extensions and rules for PHPStan",
"keywords": [
"static analysis"
],
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.15"
},
"time": "2026-02-26T10:15:59+00:00"
}
],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": {},

8
backend/phpstan.neon Normal file
View File

@@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- src
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/bootstrap_phpstan.php

View File

@@ -63,6 +63,8 @@ class SpotScoreController extends AbstractController
/** /**
* Score à un instant précis (sans cache). * 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
*/ */
private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array
{ {
@@ -81,6 +83,8 @@ class SpotScoreController extends AbstractController
/** /**
* Scores estimés par horizon à partir des forecasts les plus récents. * Scores estimés par horizon à partir des forecasts les plus récents.
* Retourne null si aucun forecast disponible. * Retourne null si aucun forecast disponible.
*
* @return array<string, array{score: int, level: string, distanceKm: float, confidence: float, validAt: string}>|null
*/ */
private function loadHorizonScores(string $spotId): ?array private function loadHorizonScores(string $spotId): ?array
{ {
@@ -125,6 +129,10 @@ class SpotScoreController extends AbstractController
return $horizons; return $horizons;
} }
/**
* @param array<string, mixed> $row
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}
*/
private function formatScore(array $row): array private function formatScore(array $row): array
{ {
return [ return [

View File

@@ -36,33 +36,38 @@ class CoastalPoint
#[ORM\Column(length: 150)] #[ORM\Column(length: 150)]
#[Groups(['spot:read'])] #[Groups(['spot:read'])]
private ?string $name = null; private string $name;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])] #[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
private mixed $geometry = null; private mixed $geometry = null;
#[ORM\Column(length: 30)] #[ORM\Column(length: 30)]
#[Groups(['spot:read'])] #[Groups(['spot:read'])]
private ?string $type = null; private string $type;
#[ORM\Column(length: 100)] #[ORM\Column(length: 100)]
#[Groups(['spot:read'])] #[Groups(['spot:read'])]
private ?string $region = null; private string $region;
/** @var Collection<int, ImpactScore> */
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')] #[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
private Collection $impactScores; private Collection $impactScores;
/** @var Collection<int, UserFeedback> */
#[ORM\OneToMany(targetEntity: UserFeedback::class, mappedBy: 'coastalPoint')] #[ORM\OneToMany(targetEntity: UserFeedback::class, mappedBy: 'coastalPoint')]
private Collection $feedbacks; private Collection $feedbacks;
public function __construct() public function __construct()
{ {
$this->name = '';
$this->type = '';
$this->region = '';
$this->impactScores = new ArrayCollection(); $this->impactScores = new ArrayCollection();
$this->feedbacks = new ArrayCollection(); $this->feedbacks = new ArrayCollection();
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
public function getName(): ?string { return $this->name; } public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; } public function setName(string $name): static { $this->name = $name; return $this; }
public function getGeometry(): mixed { return $this->geometry; } public function getGeometry(): mixed { return $this->geometry; }
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
@@ -82,9 +87,9 @@ class CoastalPoint
preg_match('/POINT\([^ ]+ ([^)]+)\)/', (string) $this->geometry, $m); preg_match('/POINT\([^ ]+ ([^)]+)\)/', (string) $this->geometry, $m);
return isset($m[1]) ? (float) $m[1] : null; return isset($m[1]) ? (float) $m[1] : null;
} }
public function getType(): ?string { return $this->type; } public function getType(): string { return $this->type; }
public function setType(string $type): static { $this->type = $type; return $this; } public function setType(string $type): static { $this->type = $type; return $this; }
public function getRegion(): ?string { return $this->region; } public function getRegion(): string { return $this->region; }
public function setRegion(string $region): static { $this->region = $region; return $this; } public function setRegion(string $region): static { $this->region = $region; return $this; }
/** @return Collection<int, ImpactScore> */ /** @return Collection<int, ImpactScore> */

View File

@@ -19,13 +19,13 @@ class DataIngestionJob
private ?Uuid $id = null; private ?Uuid $id = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
private ?string $source = null; private string $source;
#[ORM\Column(length: 100)] #[ORM\Column(length: 100)]
private ?string $tileId = null; private string $tileId;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $requestedAt = null; private \DateTimeImmutable $requestedAt;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $processedAt = null; private ?\DateTimeImmutable $processedAt = null;
@@ -41,15 +41,17 @@ class DataIngestionJob
public function __construct() public function __construct()
{ {
$this->source = '';
$this->tileId = '';
$this->requestedAt = new \DateTimeImmutable(); $this->requestedAt = new \DateTimeImmutable();
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
public function getSource(): ?string { return $this->source; } public function getSource(): string { return $this->source; }
public function setSource(string $source): static { $this->source = $source; return $this; } public function setSource(string $source): static { $this->source = $source; return $this; }
public function getTileId(): ?string { return $this->tileId; } public function getTileId(): string { return $this->tileId; }
public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; } public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; }
public function getRequestedAt(): ?\DateTimeImmutable { return $this->requestedAt; } public function getRequestedAt(): \DateTimeImmutable { return $this->requestedAt; }
public function getProcessedAt(): ?\DateTimeImmutable { return $this->processedAt; } public function getProcessedAt(): ?\DateTimeImmutable { return $this->processedAt; }
public function setProcessedAt(?\DateTimeImmutable $processedAt): static { $this->processedAt = $processedAt; return $this; } public function setProcessedAt(?\DateTimeImmutable $processedAt): static { $this->processedAt = $processedAt; return $this; }
public function getStatus(): string { return $this->status; } public function getStatus(): string { return $this->status; }

View File

@@ -29,16 +29,16 @@ class ImpactScore
#[ORM\ManyToOne(inversedBy: 'impactScores')] #[ORM\ManyToOne(inversedBy: 'impactScores')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private ?CoastalPoint $coastalPoint = null; private CoastalPoint $coastalPoint;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $timestamp = null; private \DateTimeImmutable $timestamp;
#[ORM\Column] #[ORM\Column]
private ?int $score = null; private int $score;
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
private ?string $level = null; private string $level;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?float $distanceToNearestSargassum = null; private ?float $distanceToNearestSargassum = null;
@@ -52,16 +52,18 @@ class ImpactScore
public function __construct() public function __construct()
{ {
$this->timestamp = new \DateTimeImmutable(); $this->timestamp = new \DateTimeImmutable();
$this->score = 0;
$this->level = 'low';
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } public function getCoastalPoint(): CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } public function setCoastalPoint(CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; } public function getTimestamp(): \DateTimeImmutable { return $this->timestamp; }
public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; } public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; }
public function getScore(): ?int { return $this->score; } public function getScore(): int { return $this->score; }
public function setScore(int $score): static { $this->score = $score; return $this; } public function setScore(int $score): static { $this->score = $score; return $this; }
public function getLevel(): ?string { return $this->level; } public function getLevel(): string { return $this->level; }
public function setLevel(string $level): static { $this->level = $level; return $this; } public function setLevel(string $level): static { $this->level = $level; return $this; }
public function getDistanceToNearestSargassum(): ?float { return $this->distanceToNearestSargassum; } public function getDistanceToNearestSargassum(): ?float { return $this->distanceToNearestSargassum; }
public function setDistanceToNearestSargassum(?float $d): static { $this->distanceToNearestSargassum = $d; return $this; } public function setDistanceToNearestSargassum(?float $d): static { $this->distanceToNearestSargassum = $d; return $this; }

View File

@@ -18,34 +18,37 @@ class PushSubscription
private ?Uuid $id = null; private ?Uuid $id = null;
#[ORM\Column(type: 'text', unique: true)] #[ORM\Column(type: 'text', unique: true)]
private ?string $endpoint = null; private string $endpoint;
#[ORM\Column(type: 'text')] #[ORM\Column(type: 'text')]
private ?string $authToken = null; private string $authToken;
#[ORM\Column(type: 'text')] #[ORM\Column(type: 'text')]
private ?string $p256dhKey = null; private string $p256dhKey;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?CoastalPoint $coastalPoint = null; private ?CoastalPoint $coastalPoint = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $createdAt = null; private \DateTimeImmutable $createdAt;
public function __construct() public function __construct()
{ {
$this->endpoint = '';
$this->authToken = '';
$this->p256dhKey = '';
$this->createdAt = new \DateTimeImmutable(); $this->createdAt = new \DateTimeImmutable();
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
public function getEndpoint(): ?string { return $this->endpoint; } public function getEndpoint(): string { return $this->endpoint; }
public function setEndpoint(string $endpoint): static { $this->endpoint = $endpoint; return $this; } public function setEndpoint(string $endpoint): static { $this->endpoint = $endpoint; return $this; }
public function getAuthToken(): ?string { return $this->authToken; } public function getAuthToken(): string { return $this->authToken; }
public function setAuthToken(string $authToken): static { $this->authToken = $authToken; return $this; } public function setAuthToken(string $authToken): static { $this->authToken = $authToken; return $this; }
public function getP256dhKey(): ?string { return $this->p256dhKey; } public function getP256dhKey(): string { return $this->p256dhKey; }
public function setP256dhKey(string $p256dhKey): static { $this->p256dhKey = $p256dhKey; return $this; } public function setP256dhKey(string $p256dhKey): static { $this->p256dhKey = $p256dhKey; return $this; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
} }

View File

@@ -30,54 +30,58 @@ class SargassumForecast
#[ORM\ManyToOne(inversedBy: 'forecasts')] #[ORM\ManyToOne(inversedBy: 'forecasts')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private ?SargassumObservation $sourceObservation = null; private SargassumObservation $sourceObservation;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'MULTIPOLYGON', 'srid' => 4326])] #[ORM\Column(type: 'geometry', options: ['geometry_type' => 'MULTIPOLYGON', 'srid' => 4326])]
private mixed $geometry = null; private mixed $geometry = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $validAt = null; private \DateTimeImmutable $validAt;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $computedAt = null; private \DateTimeImmutable $computedAt;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
private ?string $modelVersion = null; private string $modelVersion;
#[ORM\Column] #[ORM\Column]
private ?int $timeHorizon = null; private int $timeHorizon;
#[ORM\Column] #[ORM\Column]
private ?float $confidence = null; private float $confidence;
#[ORM\Column(type: 'geometry', nullable: true, options: ['geometry_type' => 'POINT', 'srid' => 4326])] #[ORM\Column(type: 'geometry', nullable: true, options: ['geometry_type' => 'POINT', 'srid' => 4326])]
private mixed $driftVectorAvg = null; private mixed $driftVectorAvg = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $createdAt = null; private \DateTimeImmutable $createdAt;
public function __construct() public function __construct()
{ {
$this->createdAt = new \DateTimeImmutable(); $this->validAt = new \DateTimeImmutable();
$this->computedAt = new \DateTimeImmutable(); $this->computedAt = new \DateTimeImmutable();
$this->createdAt = new \DateTimeImmutable();
$this->modelVersion = '';
$this->timeHorizon = 0;
$this->confidence = 0.0;
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
public function getSourceObservation(): ?SargassumObservation { return $this->sourceObservation; } public function getSourceObservation(): SargassumObservation { return $this->sourceObservation; }
public function setSourceObservation(?SargassumObservation $sourceObservation): static { $this->sourceObservation = $sourceObservation; return $this; } public function setSourceObservation(SargassumObservation $sourceObservation): static { $this->sourceObservation = $sourceObservation; return $this; }
public function getGeometry(): mixed { return $this->geometry; } public function getGeometry(): mixed { return $this->geometry; }
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
public function getValidAt(): ?\DateTimeImmutable { return $this->validAt; } public function getValidAt(): \DateTimeImmutable { return $this->validAt; }
public function setValidAt(\DateTimeImmutable $validAt): static { $this->validAt = $validAt; return $this; } public function setValidAt(\DateTimeImmutable $validAt): static { $this->validAt = $validAt; return $this; }
public function getComputedAt(): ?\DateTimeImmutable { return $this->computedAt; } public function getComputedAt(): \DateTimeImmutable { return $this->computedAt; }
public function setComputedAt(\DateTimeImmutable $computedAt): static { $this->computedAt = $computedAt; return $this; } public function setComputedAt(\DateTimeImmutable $computedAt): static { $this->computedAt = $computedAt; return $this; }
public function getModelVersion(): ?string { return $this->modelVersion; } public function getModelVersion(): string { return $this->modelVersion; }
public function setModelVersion(string $modelVersion): static { $this->modelVersion = $modelVersion; return $this; } public function setModelVersion(string $modelVersion): static { $this->modelVersion = $modelVersion; return $this; }
public function getTimeHorizon(): ?int { return $this->timeHorizon; } public function getTimeHorizon(): int { return $this->timeHorizon; }
public function setTimeHorizon(int $timeHorizon): static { $this->timeHorizon = $timeHorizon; return $this; } public function setTimeHorizon(int $timeHorizon): static { $this->timeHorizon = $timeHorizon; return $this; }
public function getConfidence(): ?float { return $this->confidence; } public function getConfidence(): float { return $this->confidence; }
public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; } public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; }
public function getDriftVectorAvg(): mixed { return $this->driftVectorAvg; } public function getDriftVectorAvg(): mixed { return $this->driftVectorAvg; }
public function setDriftVectorAvg(mixed $driftVectorAvg): static { $this->driftVectorAvg = $driftVectorAvg; return $this; } public function setDriftVectorAvg(mixed $driftVectorAvg): static { $this->driftVectorAvg = $driftVectorAvg; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
} }

View File

@@ -6,7 +6,6 @@ use App\Repository\SargassumObservationRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Jsor\Doctrine\PostGIS\Types\PostGISType;
use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@@ -28,25 +27,25 @@ class SargassumObservation
private mixed $bbox = null; private mixed $bbox = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $detectedAt = null; private \DateTimeImmutable $detectedAt;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
private ?string $source = null; private string $source;
#[ORM\Column(length: 100)] #[ORM\Column(length: 100)]
private ?string $tileId = null; private string $tileId;
#[ORM\Column] #[ORM\Column]
private ?float $cloudCoverage = null; private float $cloudCoverage;
#[ORM\Column] #[ORM\Column]
private ?float $confidence = null; private float $confidence;
#[ORM\Column] #[ORM\Column]
private ?float $afaiMean = null; private float $afaiMean;
#[ORM\Column] #[ORM\Column]
private ?float $afaiStd = null; private float $afaiStd;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?float $coverageArea = null; private ?float $coverageArea = null;
@@ -55,15 +54,23 @@ class SargassumObservation
private ?string $processingVersion = null; private ?string $processingVersion = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $createdAt = null; private \DateTimeImmutable $createdAt;
/** @var Collection<int, SargassumForecast> */
#[ORM\OneToMany(targetEntity: SargassumForecast::class, mappedBy: 'sourceObservation')] #[ORM\OneToMany(targetEntity: SargassumForecast::class, mappedBy: 'sourceObservation')]
private Collection $forecasts; private Collection $forecasts;
public function __construct() public function __construct()
{ {
$this->forecasts = new ArrayCollection(); $this->detectedAt = new \DateTimeImmutable();
$this->createdAt = new \DateTimeImmutable(); $this->source = '';
$this->tileId = '';
$this->cloudCoverage = 0.0;
$this->confidence = 0.0;
$this->afaiMean = 0.0;
$this->afaiStd = 0.0;
$this->createdAt = new \DateTimeImmutable();
$this->forecasts = new ArrayCollection();
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
@@ -71,25 +78,25 @@ class SargassumObservation
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
public function getBbox(): mixed { return $this->bbox; } public function getBbox(): mixed { return $this->bbox; }
public function setBbox(mixed $bbox): static { $this->bbox = $bbox; return $this; } public function setBbox(mixed $bbox): static { $this->bbox = $bbox; return $this; }
public function getDetectedAt(): ?\DateTimeImmutable { return $this->detectedAt; } public function getDetectedAt(): \DateTimeImmutable { return $this->detectedAt; }
public function setDetectedAt(\DateTimeImmutable $detectedAt): static { $this->detectedAt = $detectedAt; return $this; } public function setDetectedAt(\DateTimeImmutable $detectedAt): static { $this->detectedAt = $detectedAt; return $this; }
public function getSource(): ?string { return $this->source; } public function getSource(): string { return $this->source; }
public function setSource(string $source): static { $this->source = $source; return $this; } public function setSource(string $source): static { $this->source = $source; return $this; }
public function getTileId(): ?string { return $this->tileId; } public function getTileId(): string { return $this->tileId; }
public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; } public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; }
public function getCloudCoverage(): ?float { return $this->cloudCoverage; } public function getCloudCoverage(): float { return $this->cloudCoverage; }
public function setCloudCoverage(float $cloudCoverage): static { $this->cloudCoverage = $cloudCoverage; return $this; } public function setCloudCoverage(float $cloudCoverage): static { $this->cloudCoverage = $cloudCoverage; return $this; }
public function getConfidence(): ?float { return $this->confidence; } public function getConfidence(): float { return $this->confidence; }
public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; } public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; }
public function getAfaiMean(): ?float { return $this->afaiMean; } public function getAfaiMean(): float { return $this->afaiMean; }
public function setAfaiMean(float $afaiMean): static { $this->afaiMean = $afaiMean; return $this; } public function setAfaiMean(float $afaiMean): static { $this->afaiMean = $afaiMean; return $this; }
public function getAfaiStd(): ?float { return $this->afaiStd; } public function getAfaiStd(): float { return $this->afaiStd; }
public function setAfaiStd(float $afaiStd): static { $this->afaiStd = $afaiStd; return $this; } public function setAfaiStd(float $afaiStd): static { $this->afaiStd = $afaiStd; return $this; }
public function getCoverageArea(): ?float { return $this->coverageArea; } public function getCoverageArea(): ?float { return $this->coverageArea; }
public function setCoverageArea(?float $coverageArea): static { $this->coverageArea = $coverageArea; return $this; } public function setCoverageArea(?float $coverageArea): static { $this->coverageArea = $coverageArea; return $this; }
public function getProcessingVersion(): ?string { return $this->processingVersion; } public function getProcessingVersion(): ?string { return $this->processingVersion; }
public function setProcessingVersion(?string $processingVersion): static { $this->processingVersion = $processingVersion; return $this; } public function setProcessingVersion(?string $processingVersion): static { $this->processingVersion = $processingVersion; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
/** @return Collection<int, SargassumForecast> */ /** @return Collection<int, SargassumForecast> */
public function getForecasts(): Collection { return $this->forecasts; } public function getForecasts(): Collection { return $this->forecasts; }

View File

@@ -25,10 +25,10 @@ class UserFeedback
private ?CoastalPoint $coastalPoint = null; private ?CoastalPoint $coastalPoint = null;
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $timestamp = null; private \DateTimeImmutable $timestamp;
#[ORM\Column] #[ORM\Column]
private ?bool $hasSeaweed = null; private bool $hasSeaweed;
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
private ?string $density = null; private ?string $density = null;
@@ -38,7 +38,8 @@ class UserFeedback
public function __construct() public function __construct()
{ {
$this->timestamp = new \DateTimeImmutable(); $this->timestamp = new \DateTimeImmutable();
$this->hasSeaweed = false;
} }
public function getId(): ?Uuid { return $this->id; } public function getId(): ?Uuid { return $this->id; }
@@ -46,9 +47,9 @@ class UserFeedback
public function setLocation(mixed $location): static { $this->location = $location; return $this; } public function setLocation(mixed $location): static { $this->location = $location; return $this; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; } public function getTimestamp(): \DateTimeImmutable { return $this->timestamp; }
public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; } public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; }
public function getHasSeaweed(): ?bool { return $this->hasSeaweed; } public function getHasSeaweed(): bool { return $this->hasSeaweed; }
public function setHasSeaweed(bool $hasSeaweed): static { $this->hasSeaweed = $hasSeaweed; return $this; } public function setHasSeaweed(bool $hasSeaweed): static { $this->hasSeaweed = $hasSeaweed; return $this; }
public function getDensity(): ?string { return $this->density; } public function getDensity(): ?string { return $this->density; }
public function setDensity(?string $density): static { $this->density = $density; return $this; } public function setDensity(?string $density): static { $this->density = $density; return $this; }

View File

@@ -6,6 +6,7 @@ use App\Entity\CoastalPoint;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<CoastalPoint> */
class CoastalPointRepository extends ServiceEntityRepository class CoastalPointRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\DataIngestionJob;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<DataIngestionJob> */
class DataIngestionJobRepository extends ServiceEntityRepository class DataIngestionJobRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\ImpactScore;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<ImpactScore> */
class ImpactScoreRepository extends ServiceEntityRepository class ImpactScoreRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\PushSubscription;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<PushSubscription> */
class PushSubscriptionRepository extends ServiceEntityRepository class PushSubscriptionRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\SargassumForecast;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<SargassumForecast> */
class SargassumForecastRepository extends ServiceEntityRepository class SargassumForecastRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\SargassumObservation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<SargassumObservation> */
class SargassumObservationRepository extends ServiceEntityRepository class SargassumObservationRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -6,6 +6,7 @@ use App\Entity\UserFeedback;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<UserFeedback> */
class UserFeedbackRepository extends ServiceEntityRepository class UserFeedbackRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)

View File

@@ -102,6 +102,7 @@ class DriftSimulationService
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** @return array{lat: float, lng: float}|null */
private function getCentroid(string $observationId): ?array private function getCentroid(string $observationId): ?array
{ {
$row = $this->connection->fetchAssociative( $row = $this->connection->fetchAssociative(
@@ -167,6 +168,7 @@ class DriftSimulationService
/** /**
* Reconstruit un MULTIPOLYGON depuis un nuage de points via PostGIS. * Reconstruit un MULTIPOLYGON depuis un nuage de points via PostGIS.
*/ */
/** @param array<int, array{lat: float, lng: float}> $points */
private function reconstructPolygon(array $points): ?string private function reconstructPolygon(array $points): ?string
{ {
if (count($points) < 3) { if (count($points) < 3) {
@@ -196,6 +198,10 @@ class DriftSimulationService
/** /**
* Calcule le vecteur de dérive moyen (composantes lat/lng) sur une période. * Calcule le vecteur de dérive moyen (composantes lat/lng) sur une période.
*/ */
/**
* @param array<int, array{speed: float, direction: float}> $windData
* @return array{lat: float, lng: float}
*/
private function avgDriftVector(array $windData, int $fromHour, int $toHour): array private function avgDriftVector(array $windData, int $fromHour, int $toHour): array
{ {
$count = $toHour - $fromHour; $count = $toHour - $fromHour;

View File

@@ -49,6 +49,8 @@ class ImpactScoreService
/** /**
* Calcule et sauvegarde les scores pour tous les CoastalPoints * Calcule et sauvegarde les scores pour tous les CoastalPoints
* à partir d'une observation et de ses forecasts. * à partir d'une observation et de ses forecasts.
*
* @param array<int, SargassumForecast> $forecasts
*/ */
public function computeForObservation(SargassumObservation $observation, array $forecasts): void public function computeForObservation(SargassumObservation $observation, array $forecasts): void
{ {
@@ -87,6 +89,8 @@ class ImpactScoreService
/** /**
* Retourne le score mis en cache pour un spot (TTL 3h). * 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
*/ */
public function getCachedScore(string $spotId): ?array public function getCachedScore(string $spotId): ?array
{ {
@@ -100,6 +104,7 @@ 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 private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array
{ {
$spotId = (string) $spot->getId(); $spotId = (string) $spot->getId();
@@ -194,13 +199,21 @@ class ImpactScoreService
private function getPreviousDistance(string $spotId): ?float private function getPreviousDistance(string $spotId): ?float
{ {
// On cherche le score de la précédente "session" d'ingestion :
// au moins 6h d'ancienneté pour éviter de comparer avec un recalcul immédiat,
// et au plus 14 jours pour rester pertinent.
$since = (new \DateTimeImmutable())->modify('-14 days')->format('Y-m-d H:i:s');
$before = (new \DateTimeImmutable())->modify('-6 hours')->format('Y-m-d H:i:s');
$row = $this->connection->fetchAssociative( $row = $this->connection->fetchAssociative(
'SELECT distance_to_nearest_sargassum 'SELECT distance_to_nearest_sargassum
FROM impact_score FROM impact_score
WHERE coastal_point_id = :spotId WHERE coastal_point_id = :spotId
AND timestamp <= :before
AND timestamp >= :since
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 1', LIMIT 1',
['spotId' => $spotId] ['spotId' => $spotId, 'before' => $before, 'since' => $since]
); );
return ($row !== false && $row['distance_to_nearest_sargassum'] !== null) return ($row !== false && $row['distance_to_nearest_sargassum'] !== null)
@@ -208,6 +221,7 @@ class ImpactScoreService
: null; : null;
} }
/** @param array{score: int, level: string, distance: float|null, density: float, trend: string} $data */
private function persistScore(CoastalPoint $spot, array $data): void private function persistScore(CoastalPoint $spot, array $data): void
{ {
$s = new ImpactScore(); $s = new ImpactScore();
@@ -221,6 +235,7 @@ class ImpactScoreService
$this->em->persist($s); $this->em->persist($s);
} }
/** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null */
private function loadLatestScore(string $spotId): ?array private function loadLatestScore(string $spotId): ?array
{ {
$row = $this->connection->fetchAssociative( $row = $this->connection->fetchAssociative(
@@ -265,6 +280,7 @@ class ImpactScoreService
}; };
} }
/** @return array{score: int, level: string, distance: null, density: float, trend: string} */
private function emptyScore(): array 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'];
@@ -291,10 +307,11 @@ class ImpactScoreService
return ($row !== false && (int) $row['positive_count'] > 0) ? (float) self::FEEDBACK_BONUS : 0.0; return ($row !== false && (int) $row['positive_count'] > 0) ? (float) self::FEEDBACK_BONUS : 0.0;
} }
/** @param array<int, SargassumForecast> $forecasts */
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
{ {
foreach ($forecasts as $f) { foreach ($forecasts as $f) {
if ($f instanceof SargassumForecast && $f->getTimeHorizon() === $horizon) { if ($f->getTimeHorizon() === $horizon) {
return $f; return $f;
} }
} }

View File

@@ -31,6 +31,7 @@ class IngestionService
* Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la * Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la
* couverture nuageuse est trop élevée ou si la scène S2 est indisponible. * couverture nuageuse est trop élevée ou si la scène S2 est indisponible.
*/ */
/** @param array<int, float> $bbox */
public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void
{ {
try { try {
@@ -41,6 +42,7 @@ class IngestionService
} }
} }
/** @param array<int, float> $bbox */
public function ingest( public function ingest(
array $bbox, array $bbox,
\DateTimeImmutable $date, \DateTimeImmutable $date,
@@ -177,6 +179,9 @@ class IngestionService
} }
} }
/**
* @return array{type: string, coordinates: array<int, mixed>}|null
*/
private function buildMultiPolygon(string $geojsonPath): ?array private function buildMultiPolygon(string $geojsonPath): ?array
{ {
if (!file_exists($geojsonPath)) { if (!file_exists($geojsonPath)) {

View File

@@ -48,6 +48,7 @@ class SentinelHubClient
* Retourne la couverture nuageuse (%) de la meilleure scène disponible. * Retourne la couverture nuageuse (%) de la meilleure scène disponible.
* collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci' * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/ */
/** @param array<int, float> $bbox */
public function getCloudCoverage(array $bbox, \DateTimeImmutable $date, string $collection = 'sentinel-2-l2a'): float public function getCloudCoverage(array $bbox, \DateTimeImmutable $date, string $collection = 'sentinel-2-l2a'): float
{ {
// Sentinel-3 OLCI n'expose pas eo:cloud_cover dans le Catalog ; on renvoie 0 // Sentinel-3 OLCI n'expose pas eo:cloud_cover dans le Catalog ; on renvoie 0
@@ -83,6 +84,10 @@ class SentinelHubClient
* calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath. * calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath.
* collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci' * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/ */
/**
* @param array<int, float> $bbox
* @return array{tileId: string, afaiMean: float, afaiStd: float}
*/
public function downloadAfaiBinary( public function downloadAfaiBinary(
array $bbox, array $bbox,
\DateTimeImmutable $date, \DateTimeImmutable $date,

View File

@@ -70,6 +70,15 @@
"config/packages/nelmio_cors.yaml" "config/packages/nelmio_cors.yaml"
] ]
}, },
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
}
},
"symfony/console": { "symfony/console": {
"version": "7.4", "version": "7.4",
"recipe": { "recipe": {

View File

@@ -0,0 +1,13 @@
<?php
use App\Kernel;
require_once __DIR__ . '/../vendor/autoload.php';
// Variables minimales pour booter le kernel sans vraie DB
$_ENV['DATABASE_URL'] = $_ENV['DATABASE_URL'] ?? 'postgresql://user:pass@localhost:5432/phpstan_dummy?serverVersion=16';
$_ENV['APP_SECRET'] = $_ENV['APP_SECRET'] ?? 'phpstan-dummy-secret';
$_SERVER['DATABASE_URL'] = $_ENV['DATABASE_URL'];
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'];
$kernel = new Kernel('dev', true);
$kernel->boot();
return $kernel->getContainer()->get('doctrine')->getManager();

View File

@@ -124,6 +124,25 @@
.gauge--medium .spot-panel__gauge-fill { background: #eab308; } .gauge--medium .spot-panel__gauge-fill { background: #eab308; }
.gauge--high .spot-panel__gauge-fill { background: #ef4444; } .gauge--high .spot-panel__gauge-fill { background: #ef4444; }
/* Trend */
.spot-panel__trend {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border-radius: 12px;
border: 2px solid rgba(0,0,0,0.12);
margin-bottom: 12px;
font-weight: 600;
font-size: 14px;
}
.trend--bad { background: #fee2e2; color: #b91c1c; }
.trend--neutral { background: #f1f5f9; color: #475569; }
.trend--good { background: #dcfce7; color: #15803d; }
.spot-panel__trend-icon { font-size: 18px; line-height: 1; }
.spot-panel__trend-label { font-family: 'Fredoka', sans-serif; }
/* Details */ /* Details */
.spot-panel__details { .spot-panel__details {
display: grid; display: grid;

View File

@@ -6,6 +6,12 @@ import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' }; const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' }; const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
const TREND_CONFIG = {
increasing: { icon: '📈', label: 'Situation en dégradation', cls: 'trend--bad' },
stable: { icon: '➡️', label: 'Situation stable', cls: 'trend--neutral' },
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
};
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' };
export default function SpotPanel({ spot, activeStep, onClose }) { export default function SpotPanel({ spot, activeStep, onClose }) {
@@ -82,13 +88,20 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
</div> </div>
</div> </div>
{activeStep === 'now' && displayScore.trend && (() => {
const t = TREND_CONFIG[displayScore.trend] ?? TREND_CONFIG.stable;
return (
<div className={`spot-panel__trend ${t.cls}`}>
<span className="spot-panel__trend-icon">{t.icon}</span>
<span className="spot-panel__trend-label">{t.label}</span>
</div>
);
})()}
<dl className="spot-panel__details"> <dl className="spot-panel__details">
{displayScore.distance != null && ( {displayScore.distance != null && (
<><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></> <><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></>
)} )}
{activeStep === 'now' && displayScore.trend && (
<><dt>Tendance</dt><dd>{displayScore.trend}</dd></>
)}
{activeStep === 'now' && displayScore.computedAt && ( {activeStep === 'now' && displayScore.computedAt && (
<><dt>Calculé le</dt> <><dt>Calculé le</dt>
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></> <dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>