@ -0,0 +1,12 @@ | |||
<?php | |||
namespace OSM; | |||
use OSM\Point; | |||
class Box { | |||
public Point $mininum; | |||
public Point $maximum; | |||
} |
@ -0,0 +1,70 @@ | |||
<?php | |||
namespace OSM\Element; | |||
use OSM\Element\Member\Member; | |||
use OSM\Element\Tag; | |||
class Element { | |||
public ?int $id = null; | |||
public array $members = []; | |||
public array $tags = []; | |||
public static function createFromArray($array) { | |||
assert(is_array($array)); | |||
$hasType = isset($array['type']); | |||
assert($hasType); | |||
$className = __NAMESPACE__.'\\'.ucfirst($array['type']); | |||
$hasClass = class_exists($className); | |||
$instance = new $className(); | |||
$hasId = isset($array['id']); | |||
assert($hasId); | |||
$instance->id = (int) $array['id']; | |||
$hasMembers = isset($array['members']); | |||
if ($hasMembers) { | |||
$items = $array['members']; | |||
foreach ($items as $item) { | |||
$member = Member::createFromArray($item); | |||
$instance->members[] = $member; | |||
} | |||
} | |||
$hasTags = isset($array['tags']); | |||
if ($hasTags) { | |||
$items = $array['tags']; | |||
foreach ($items as $itemKey => $itemValue) { | |||
$tag = Tag::createFromValues($itemKey, $itemValue); | |||
$instance->tags[] = $tag; | |||
} | |||
} | |||
$instance->completeFromArray($array); | |||
return $instance; | |||
} | |||
public function completeFromArray(array $array): static | |||
{ | |||
return $this; | |||
} | |||
public function getTagValue(string $key): ?string { | |||
$foundTags = array_filter($this->tags, function ($tag) use ($key) { | |||
return $tag->key === $key; | |||
}); | |||
if (count($foundTags) !== 1) { | |||
return null; | |||
} | |||
$tag = reset($foundTags); | |||
return $tag->value; | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
<?php | |||
namespace OSM\Element\Member; | |||
class Member { | |||
public int $ref; | |||
public string $role; | |||
public static function createFromArray($array) { | |||
assert(is_array($array)); | |||
$hasType = isset($array['type']); | |||
assert($hasType); | |||
$className = __NAMESPACE__.'\\'.ucfirst($array['type']); | |||
$hasClass = class_exists($className); | |||
$instance = new $className(); | |||
$hasRef = isset($array['ref']); | |||
assert($hasRef); | |||
$instance->ref = (int) $array['ref']; | |||
$hasRole = isset($array['role']); | |||
assert($hasRole); | |||
$instance->role = (string) $array['role']; | |||
$instance->completeFromArray($array); | |||
return $instance; | |||
} | |||
public function isSame(Member $other): bool { | |||
return ($this->ref === $other->ref); | |||
} | |||
} |
@ -0,0 +1,19 @@ | |||
<?php | |||
namespace OSM\Element\Member; | |||
use OSM\Element\Member\Member; | |||
use OSM\Point; | |||
class Node extends Member { | |||
public Point $point; | |||
public function completeFromArray(array $array): static | |||
{ | |||
$this->point = Point::createFromArray($array); | |||
return $this; | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
<?php | |||
namespace OSM\Element\Member; | |||
use OSM\Element\Member\Member; | |||
use OSM\Point; | |||
class Way extends Member { | |||
public array $points = []; | |||
public function completeFromArray(array $array): static | |||
{ | |||
$hasGeometry = isset($array['geometry']); | |||
if ($hasGeometry) { | |||
$items = $array['geometry']; | |||
foreach ($items as $item) { | |||
$point = Point::createFromArray($item); | |||
$this->points[] = $point; | |||
} | |||
} | |||
return $this; | |||
} | |||
public function getFirstPoint(): Point { | |||
return reset($this->points); | |||
} | |||
public function getLastPoint(): Point { | |||
return end($this->points); | |||
} | |||
public function reversePoints(): static { | |||
$this->points = array_reverse($this->points); | |||
return $this; | |||
} | |||
} |
@ -0,0 +1,86 @@ | |||
<?php | |||
namespace OSM\Element; | |||
use OSM\Element\Element; | |||
use OSM\Element\Member\Way; | |||
use OSM\Point; | |||
class Relation extends Element { | |||
public function getOuterWays(): array { | |||
return array_filter($this->members, function ($member) { | |||
return ( | |||
($member instanceof \OSM\Element\Member\Way) | |||
and ($member->role === 'outer') | |||
); | |||
}); | |||
} | |||
public function getOuterWaysStartingWith(Point $point, ?Way $exclude = null): array { | |||
return array_filter($this->members, function ($member) use ($point, $exclude) { | |||
return ( | |||
($member instanceof \OSM\Element\Member\Way) | |||
and ($member->role === 'outer') | |||
and ($point->isSame($member->getFirstPoint())) | |||
and ( | |||
is_null($exclude) | |||
or !$member->isSame($exclude) | |||
) | |||
); | |||
}); | |||
} | |||
public function getOuterWaysStopingWith(Point $point, ?Way $exclude = null): array { | |||
return array_filter($this->members, function ($member) use ($point, $exclude) { | |||
return ( | |||
($member instanceof \OSM\Element\Member\Way) | |||
and ($member->role === 'outer') | |||
and ($point->isSame($member->getLastPoint())) | |||
and ( | |||
is_null($exclude) | |||
or !$member->isSame($exclude) | |||
) | |||
); | |||
}); | |||
} | |||
public function getOrderedOuterWays(): array { | |||
$orderedWays = []; | |||
$ways = $this->getOuterWays(); | |||
$currentWay = reset($ways); | |||
$veryFirstPoint = $currentWay->getFirstPoint(); | |||
$isDone = false; | |||
while (!$isDone) { | |||
$orderedWays[] = $currentWay; | |||
$nextWays = $this->getOuterWaysStartingWith($currentWay->getLastPoint(), $currentWay); | |||
assert(count($nextWays) <= 1); | |||
if (count($nextWays) === 1) { | |||
$nextWay = reset($nextWays); | |||
if ($veryFirstPoint->isSame($nextWay->getFirstPoint())) { | |||
break; | |||
} | |||
$currentWay = $nextWay; | |||
continue; | |||
} else { | |||
$nextWays = $this->getOuterWaysStopingWith($currentWay->getLastPoint(), $currentWay); | |||
assert(count($nextWays) <= 1); | |||
if (count($nextWays) === 1) { | |||
$nextWay = reset($nextWays); | |||
$nextWay->reversePoints(); | |||
if ($veryFirstPoint->isSame($nextWay->getFirstPoint())) { | |||
break; | |||
} | |||
$currentWay = $nextWay; | |||
continue; | |||
} else { | |||
$isDone = true; | |||
} | |||
} | |||
} | |||
return $orderedWays; | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
<?php | |||
namespace OSM\Element; | |||
class Tag { | |||
public ?string $key; | |||
public ?string $value; | |||
public static function createFromValues( | |||
string $key, | |||
string $value | |||
): static | |||
{ | |||
$instance = new self(); | |||
$instance->key = $key; | |||
$instance->value = $value; | |||
return $instance; | |||
} | |||
} |
@ -0,0 +1,25 @@ | |||
<?php | |||
namespace OSM; | |||
class GeoJsonConverter { | |||
public static function convertRelationToPolygon( | |||
\OSM\Element\Relation $relation | |||
): \GeoJson\Geometry\Polygon | |||
{ | |||
$positions = []; | |||
foreach ($relation->getOrderedOuterWays() as $way) { | |||
foreach ($way->points as $point) { | |||
$positions[] = new \GeoJson\Geometry\Point([ | |||
$point->longitude, | |||
$point->latitude | |||
]); | |||
} | |||
} | |||
return new \GeoJson\Geometry\Polygon([ $positions ]); | |||
} | |||
} |
@ -0,0 +1,25 @@ | |||
<?php | |||
namespace OSM; | |||
use OSM\Element\Element; | |||
class OSM { | |||
public array $elements = []; | |||
public static function createFromJson($json) { | |||
$array = json_decode($json, true); | |||
$instance = new self(); | |||
$items = $array['elements']; | |||
foreach ($items as $item) { | |||
$element = Element::createFromArray($item); | |||
$instance->elements[] = $element; | |||
} | |||
return $instance; | |||
} | |||
} |
@ -0,0 +1,30 @@ | |||
<?php | |||
namespace OSM; | |||
class Point { | |||
public float $latitude; | |||
public float $longitude; | |||
public static function createFromArray(array $array) | |||
{ | |||
$hasLat = isset($array['lat']); | |||
$hasLon = isset($array['lon']); | |||
assert($hasLat and $hasLon); | |||
$instance = new self(); | |||
$instance->latitude = (float) $array['lat']; | |||
$instance->longitude = (float) $array['lon']; | |||
return $instance; | |||
} | |||
public function isSame(Point $other): bool { | |||
$isSame = ( | |||
($other->latitude === $this->latitude) | |||
and ($other->longitude === $this->longitude) | |||
); | |||
return $isSame; | |||
} | |||
} |
@ -0,0 +1,4 @@ | |||
upload_max_filesize = 40M | |||
post_max_size = 40M | |||
memory_limit = 256M | |||
@ -0,0 +1,96 @@ | |||
<?php | |||
namespace App\Controller; | |||
use App\Form\CityToolType; | |||
use App\Service\OverpassClient; | |||
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; | |||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
use Symfony\Bundle\SecurityBundle\Security; | |||
use Symfony\Component\Form\Extension\Core\Type\SubmitType; | |||
use Symfony\Component\HttpFoundation\HeaderUtils; | |||
use Symfony\Component\HttpFoundation\Request; | |||
use Symfony\Component\HttpFoundation\Response; | |||
use Symfony\Component\HttpFoundation\ResponseHeaderBag; | |||
use Symfony\Component\HttpFoundation\StreamedResponse; | |||
use Symfony\Component\Routing\Attribute\Route; | |||
#[Route('/tools')] | |||
class ToolsController extends AbstractController | |||
{ | |||
#[Route('/', name: 'app_tools')] | |||
public function index(): Response | |||
{ | |||
return $this->render('tools/index.html.twig', [ | |||
]); | |||
} | |||
#[Route('/city', name: 'app_tools_city')] | |||
public function city( | |||
Request $request, | |||
OverpassClient $overpass, | |||
): Response | |||
{ | |||
$form = $this->createForm(CityToolType::class, []); | |||
$form->add('submit', SubmitType::class, ['label' => 'Générer']); | |||
$form->handleRequest($request); | |||
if ($form->isSubmitted() and $form->isValid()) { | |||
$areaId = $form->get('area')->getData(); | |||
$query = sprintf('relation["boundary"="administrative"]["admin_level"="8"]["name"](area:%d);', 3600000000 + $areaId); // Cf. <https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL#By_area_.28area.29> | |||
$json = $overpass->query($query); | |||
$response = new StreamedResponse(); | |||
$response->headers->set('Content-Type', 'text/csv'); | |||
$response->headers->set( | |||
'Content-Disposition', | |||
HeaderUtils::makeDisposition( | |||
HeaderUtils::DISPOSITION_ATTACHMENT, | |||
sprintf('cities-in-%d.csv', $areaId) | |||
) | |||
); | |||
$response->setCallback(function () use ($json): void { | |||
$headings = [ | |||
'name', | |||
'description', | |||
'osm', | |||
'geojson', | |||
'status', | |||
]; | |||
$csv = fopen('php://output', 'a'); | |||
fputcsv($csv, $headings); | |||
$osm = \OSM\OSM::createFromJson($json); | |||
foreach ($osm->elements as $relation) { | |||
$name = $relation->getTagValue('name'); | |||
$feature = new \GeoJson\Feature\Feature( | |||
\OSM\GeoJsonConverter::convertRelationToPolygon($relation), | |||
['name' => $name] | |||
); | |||
fputcsv($csv, [ | |||
$name, | |||
$name, | |||
'', | |||
json_encode(new \GeoJson\Feature\FeatureCollection([ $feature ])), | |||
'todo', | |||
]); | |||
} | |||
fclose($csv); | |||
}); | |||
return $response; | |||
} | |||
return $this->render('tools/city.html.twig', [ | |||
'form' => $form, | |||
]); | |||
} | |||
} |
@ -0,0 +1,56 @@ | |||
<?php | |||
namespace App\Form; | |||
use App\Service\OverpassClient; | |||
use Symfony\Component\Form\AbstractType; | |||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | |||
use Symfony\Component\Form\Extension\Core\Type\NumberType; | |||
use Symfony\Component\Form\FormBuilderInterface; | |||
use Symfony\Component\HttpFoundation\RequestStack; | |||
use Symfony\Component\Validator\Constraints\File; | |||
class CityToolType extends AbstractType | |||
{ | |||
public function __construct( | |||
private OverpassClient $overpass, | |||
private RequestStack $requestStack, | |||
) { | |||
} | |||
public function buildForm(FormBuilderInterface $builder, array $options): void | |||
{ | |||
$session = $this->requestStack->getSession(); | |||
$choices = $session->has('city_tool_choices') ? $session->get('city_tool_choices') : []; | |||
if (empty($choices)) { | |||
$entries = $this->overpass->rawQuery('[out:csv(::id,"ref","name")][timeout:25];area(3602202162)->.searchArea;relation["name"]["boundary"="administrative"]["border_type"="departement"]["admin_level"="6"](area.searchArea);out;'); | |||
$col = []; | |||
$row = []; | |||
$entries = explode("\n", $entries); | |||
foreach ($entries as $index => $entry) { | |||
$entry = explode("\t", $entry); | |||
if ($index === 0) { | |||
$col = $entry; | |||
} else { | |||
if (count($col) === count($entry)) { | |||
$row = array_combine($col, $entry); | |||
$key = sprintf('%s %s', $row['ref'], $row['name']); | |||
$val = (int) $row['@id']; | |||
$choices[$key] = $val; | |||
} | |||
} | |||
} | |||
ksort($choices); | |||
} | |||
$session->set('city_tool_choices', $choices); | |||
$builder | |||
->add('area', ChoiceType::class, [ | |||
'label' => 'Département', | |||
'choices' => $choices, | |||
]) | |||
; | |||
} | |||
} |
@ -0,0 +1,14 @@ | |||
{% extends 'base.html.twig' %} | |||
{% block page_title %}Outils{% endblock %} | |||
{% block page_content %} | |||
<ul class="nav nav-tabs mb-3"> | |||
{% for tool in tools %} | |||
<li class="nav-item"> | |||
<a class="nav-link{% if app.current_route == tool.route %} active" aria-current="page{% endif %}" href="{{ path(tool.route) }}">{{ tool.label }}</a> | |||
</li> | |||
{% endfor %} | |||
</ul> | |||
{% block tools_content %}{% endblock %} | |||
{% endblock %} |
@ -0,0 +1,5 @@ | |||
{% extends 'tools/base.html.twig' %} | |||
{% block tools_content %} | |||
{{ form(form) }} | |||
{% endblock %} |
@ -0,0 +1,2 @@ | |||
{% extends 'tools/base.html.twig' %} | |||