From 3a692839837ab8f68b5f486e33bb4c03dd3848e9 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Thu, 9 Apr 2026 00:31:40 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20import=20points=20c=C3=B4tiers=20depuis?= =?UTF-8?q?=20OpenStreetMap=20(Overpass=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migrations/Version20260409000000.php | 28 +++ .../Command/ImportCoastalPointsCommand.php | 186 ++++++++++++++++++ backend/src/Entity/CoastalPoint.php | 5 + 3 files changed, 219 insertions(+) create mode 100644 backend/migrations/Version20260409000000.php create mode 100644 backend/src/Command/ImportCoastalPointsCommand.php diff --git a/backend/migrations/Version20260409000000.php b/backend/migrations/Version20260409000000.php new file mode 100644 index 0000000..4c20b0a --- /dev/null +++ b/backend/migrations/Version20260409000000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE coastal_point ADD osm_id VARCHAR(30) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX uniq_coastal_osm_id ON coastal_point (osm_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX uniq_coastal_osm_id'); + $this->addSql('ALTER TABLE coastal_point DROP COLUMN osm_id'); + } +} diff --git a/backend/src/Command/ImportCoastalPointsCommand.php b/backend/src/Command/ImportCoastalPointsCommand.php new file mode 100644 index 0000000..54ccfaf --- /dev/null +++ b/backend/src/Command/ImportCoastalPointsCommand.php @@ -0,0 +1,186 @@ + ['bbox' => '15.7,-62.0,16.7,-60.8', 'region' => 'Guadeloupe'], + 'Martinique' => ['bbox' => '14.3,-61.3,14.9,-60.7', 'region' => 'Martinique'], + 'Sainte-Lucie' => ['bbox' => '13.7,-61.1,14.2,-60.8', 'region' => 'Sainte-Lucie'], + 'Barbade' => ['bbox' => '13.0,-59.8,13.4,-59.3', 'region' => 'Barbade'], + 'Saint-Martin' => ['bbox' => '17.8,-63.2,18.2,-62.9', 'region' => 'Saint-Martin'], + 'Dominique' => ['bbox' => '15.2,-61.5,15.7,-61.2', 'region' => 'Dominique'], + 'Saint-Barthélemy' => ['bbox' => '17.85,-62.9,17.98,-62.7', 'region' => 'Saint-Barthélemy'], + 'Marie-Galante' => ['bbox' => '15.8,-61.4,16.1,-61.1', 'region' => 'Guadeloupe'], + 'Les Saintes' => ['bbox' => '15.8,-61.7,15.9,-61.5', 'region' => 'Guadeloupe'], + ]; + + public function __construct( + private EntityManagerInterface $em, + private HttpClientInterface $httpClient, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche sans persister en base') + ->addOption('island', null, InputOption::VALUE_REQUIRED, 'Importer une seule île (ex: Guadeloupe)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + $filter = $input->getOption('island'); + + $io->title('Import OpenStreetMap — Points côtiers Antilles'); + + if ($dryRun) { + $io->note('Mode dry-run : aucune écriture en base'); + } + + $repo = $this->em->getRepository(CoastalPoint::class); + $created = 0; + $skipped = 0; + + $islands = self::ISLANDS; + if (is_string($filter)) { + $islands = array_filter( + $islands, + static fn(string $k) => mb_strtolower($k) === mb_strtolower($filter), + ARRAY_FILTER_USE_KEY + ); + if (empty($islands)) { + $io->error("Île inconnue : {$filter}. Valeurs : " . implode(', ', array_keys(self::ISLANDS))); + return Command::FAILURE; + } + } + + foreach ($islands as $islandName => $config) { + $io->section("→ {$islandName}"); + $elements = $this->queryOverpass($config['bbox'], $io); + + foreach ($elements as $el) { + $osmId = $el['type'] . '/' . $el['id']; + $osmName = $el['tags']['name'] ?? null; + + if ($osmName === null) { + continue; + } + + // Idempotence par osmId + if ($repo->findOneBy(['osmId' => $osmId]) !== null) { + ++$skipped; + continue; + } + + // Idempotence par nom+région (couvre les points seedés manuellement) + if ($repo->findOneBy(['name' => $osmName, 'region' => $config['region']]) !== null) { + ++$skipped; + continue; + } + + $lat = $el['lat'] ?? ($el['center']['lat'] ?? null); + $lon = $el['lon'] ?? ($el['center']['lon'] ?? null); + + if ($lat === null || $lon === null) { + continue; + } + + $type = $this->mapType($el['tags']); + + $io->writeln(sprintf(' + %-45s [%s]', $osmName, $type)); + + if (!$dryRun) { + $point = new CoastalPoint(); + $point->setName($osmName); + $point->setGeometry(sprintf('SRID=4326;POINT(%f %f)', $lon, $lat)); + $point->setType($type); + $point->setRegion($config['region']); + $point->setOsmId($osmId); + $this->em->persist($point); + } + + ++$created; + } + + if (!$dryRun) { + $this->em->flush(); + } + + sleep(1); // respect Overpass rate limit + } + + $verb = $dryRun ? 'trouvé(s)' : 'créé(s)'; + $io->success(sprintf('%d point(s) %s, %d ignoré(s) (déjà existants).', $created, $verb, $skipped)); + + return Command::SUCCESS; + } + + /** + * @return array> + */ + private function queryOverpass(string $bbox, SymfonyStyle $io): array + { + $query = <<httpClient->request('POST', self::OVERPASS_URL, [ + 'body' => ['data' => $query], + 'timeout' => 35, + ]); + + $data = $response->toArray(); + + return $data['elements'] ?? []; + } catch (\Throwable $e) { + $io->warning('Overpass error : ' . $e->getMessage()); + return []; + } + } + + /** + * @param array $tags + */ + private function mapType(array $tags): string + { + if (($tags['sport'] ?? '') === 'surfing') return 'surf'; + if (($tags['harbour'] ?? '') === 'fishing') return 'fishing'; + if (($tags['amenity'] ?? '') === 'ferry_terminal') return 'port'; + if (($tags['leisure'] ?? '') === 'marina') return 'port'; + return 'beach'; + } +} diff --git a/backend/src/Entity/CoastalPoint.php b/backend/src/Entity/CoastalPoint.php index f9079ad..4a79fca 100644 --- a/backend/src/Entity/CoastalPoint.php +++ b/backend/src/Entity/CoastalPoint.php @@ -49,6 +49,9 @@ class CoastalPoint #[Groups(['spot:read'])] private string $region; + #[ORM\Column(length: 30, nullable: true, unique: true)] + private ?string $osmId = null; + /** @var Collection */ #[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')] private Collection $impactScores; @@ -91,6 +94,8 @@ class CoastalPoint public function setType(string $type): static { $this->type = $type; return $this; } public function getRegion(): string { return $this->region; } public function setRegion(string $region): static { $this->region = $region; return $this; } + public function getOsmId(): ?string { return $this->osmId; } + public function setOsmId(?string $osmId): static { $this->osmId = $osmId; return $this; } /** @return Collection */ public function getImpactScores(): Collection { return $this->impactScores; }