feat: import points côtiers depuis OpenStreetMap (Overpass API)
This commit is contained in:
28
backend/migrations/Version20260409000000.php
Normal file
28
backend/migrations/Version20260409000000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260409000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajoute osm_id sur coastal_point pour idempotence import OSM';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
186
backend/src/Command/ImportCoastalPointsCommand.php
Normal file
186
backend/src/Command/ImportCoastalPointsCommand.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\CoastalPoint;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:import-coastal-points',
|
||||||
|
description: 'Importe les plages/ports/spots depuis OpenStreetMap (Overpass API) — idempotent',
|
||||||
|
)]
|
||||||
|
class ImportCoastalPointsCommand extends Command
|
||||||
|
{
|
||||||
|
private const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||||
|
|
||||||
|
// bbox format Overpass : sud,ouest,nord,est
|
||||||
|
private const ISLANDS = [
|
||||||
|
'Guadeloupe' => ['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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function queryOverpass(string $bbox, SymfonyStyle $io): array
|
||||||
|
{
|
||||||
|
$query = <<<OVERPASS
|
||||||
|
[out:json][timeout:30];
|
||||||
|
(
|
||||||
|
node["natural"="beach"]["name"]({$bbox});
|
||||||
|
node["leisure"="marina"]["name"]({$bbox});
|
||||||
|
node["harbour"="fishing"]["name"]({$bbox});
|
||||||
|
node["amenity"="ferry_terminal"]["name"]({$bbox});
|
||||||
|
node["sport"="surfing"]["name"]({$bbox});
|
||||||
|
way["natural"="beach"]["name"]({$bbox});
|
||||||
|
way["leisure"="marina"]["name"]({$bbox});
|
||||||
|
way["harbour"="fishing"]["name"]({$bbox});
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
OVERPASS;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->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<string, string> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ class CoastalPoint
|
|||||||
#[Groups(['spot:read'])]
|
#[Groups(['spot:read'])]
|
||||||
private string $region;
|
private string $region;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 30, nullable: true, unique: true)]
|
||||||
|
private ?string $osmId = null;
|
||||||
|
|
||||||
/** @var Collection<int, ImpactScore> */
|
/** @var Collection<int, ImpactScore> */
|
||||||
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
|
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
|
||||||
private Collection $impactScores;
|
private Collection $impactScores;
|
||||||
@@ -91,6 +94,8 @@ class CoastalPoint
|
|||||||
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; }
|
||||||
|
public function getOsmId(): ?string { return $this->osmId; }
|
||||||
|
public function setOsmId(?string $osmId): static { $this->osmId = $osmId; return $this; }
|
||||||
|
|
||||||
/** @return Collection<int, ImpactScore> */
|
/** @return Collection<int, ImpactScore> */
|
||||||
public function getImpactScores(): Collection { return $this->impactScores; }
|
public function getImpactScores(): Collection { return $this->impactScores; }
|
||||||
|
|||||||
Reference in New Issue
Block a user