feat: import points côtiers depuis OpenStreetMap (Overpass API)
This commit is contained in:
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user