feat: import points côtiers depuis OpenStreetMap (Overpass API)

This commit is contained in:
Gwadaking
2026-04-09 00:31:40 -04:00
parent 3c2d174edb
commit 3a69283983
3 changed files with 219 additions and 0 deletions

View 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';
}
}