Browse Source

Ajoute l'outils pour créer tâches par communes de département

master
vincent 2 weeks ago
parent
commit
20b321c49e
24 changed files with 594 additions and 9 deletions
  1. +9
    -1
      assets/controllers/map_controller.js
  2. +3
    -2
      composer.json
  3. +1
    -1
      composer.lock
  4. +9
    -1
      config/packages/twig.yaml
  5. +12
    -0
      lib/OSM/Box.php
  6. +70
    -0
      lib/OSM/Element/Element.php
  7. +38
    -0
      lib/OSM/Element/Member/Member.php
  8. +19
    -0
      lib/OSM/Element/Member/Node.php
  9. +38
    -0
      lib/OSM/Element/Member/Way.php
  10. +86
    -0
      lib/OSM/Element/Relation.php
  11. +22
    -0
      lib/OSM/Element/Tag.php
  12. +25
    -0
      lib/OSM/GeoJsonConverter.php
  13. +25
    -0
      lib/OSM/OSM.php
  14. +30
    -0
      lib/OSM/Point.php
  15. +4
    -0
      php.ini
  16. +6
    -4
      src/Controller/TaskController.php
  17. +96
    -0
      src/Controller/ToolsController.php
  18. +5
    -0
      src/Entity/Task.php
  19. +56
    -0
      src/Form/CityToolType.php
  20. +16
    -0
      src/Service/OverpassClient.php
  21. +3
    -0
      templates/project/show.html.twig
  22. +14
    -0
      templates/tools/base.html.twig
  23. +5
    -0
      templates/tools/city.html.twig
  24. +2
    -0
      templates/tools/index.html.twig

+ 9
- 1
assets/controllers/map_controller.js View File

@ -133,7 +133,15 @@ export default class extends Controller {
case 'warning' : color = '#ffc107'; break
case 'success' : color = '#198754'; break
}
return {color: color};
if (feature.geometry.type === 'Polygon') {
return {
color: color,
weight: 1,
fillOpacity: 0.5,
};
} else {
return {color: color};
}
}
}).bindTooltip(feature.properties.name).addTo(taskLayer).on('click', function (event) {
window.location.href = event.layer.feature.properties.url;


+ 3
- 2
composer.json View File

@ -12,7 +12,7 @@
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.2",
"jbelien/oauth2-openstreetmap": "^0.1.2",
"jmikola/geojson": "^1.0",
"jmikola/geojson": "^1.2",
"knplabs/knp-paginator-bundle": "^6.4",
"knpuniversity/oauth2-client-bundle": "^2.18",
"league/commonmark": "^2.4",
@ -58,7 +58,8 @@
},
"autoload": {
"psr-4": {
"App\\": "src/"
"App\\": "src/",
"OSM\\": "lib/OSM/"
}
},
"autoload-dev": {


+ 1
- 1
composer.lock View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "849dadca70e58b40b2c1cc3989eb53ac",
"content-hash": "b5bbf254d893ea1ef44505e824cdf2b9",
"packages": [
{
"name": "behat/transliterator",


+ 9
- 1
config/packages/twig.yaml View File

@ -5,12 +5,20 @@ twig:
title: '%short_title%'
menu:
header:
- { route: 'app_project', label: 'Projets', icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-folder" viewBox="0 0 16 16"><path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.826a2 2 0 0 1-1.991-1.819l-.637-7a2 2 0 0 1 .342-1.31zM2.19 4a1 1 0 0 0-.996 1.09l.637 7a1 1 0 0 0 .995.91h10.348a1 1 0 0 0 .995-.91l.637-7A1 1 0 0 0 13.81 4zm4.69-1.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139q.323-.119.684-.12h5.396z"/></svg>' }
- route: 'app_project'
label: 'Projets'
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-folder" viewBox="0 0 16 16"><path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.826a2 2 0 0 1-1.991-1.819l-.637-7a2 2 0 0 1 .342-1.31zM2.19 4a1 1 0 0 0-.996 1.09l.637 7a1 1 0 0 0 .995.91h10.348a1 1 0 0 0 .995-.91l.637-7A1 1 0 0 0 13.81 4zm4.69-1.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139q.323-.119.684-.12h5.396z"/></svg>'
- route: 'app_tools'
label: 'Outils'
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-wrench" viewBox="0 0 16 16"><path d="M.102 2.223A3.004 3.004 0 0 0 3.78 5.897l6.341 6.252A3.003 3.003 0 0 0 13 16a3 3 0 1 0-.851-5.878L5.897 3.781A3.004 3.004 0 0 0 2.223.1l2.141 2.142L4 4l-1.757.364zm13.37 9.019.528.026.287.445.445.287.026.529L15 13l-.242.471-.026.529-.445.287-.287.445-.529.026L13 15l-.471-.242-.529-.026-.287-.445-.445-.287-.026-.529L11 13l.242-.471.026-.529.445-.287.287-.445.529-.026L13 11z"/></svg>'
taskLifecycleManager: '@App\Service\TaskLifecycleManager'
geoJsonManager: '@App\Service\GeoJsonManager'
sourceGenerator: '@App\Service\SourceGenerator'
short_title: '%short_title%'
long_title: '%long_title%'
tools:
- label: 'Communes'
route: 'app_tools_city'
when@test:
twig:


+ 12
- 0
lib/OSM/Box.php View File

@ -0,0 +1,12 @@
<?php
namespace OSM;
use OSM\Point;
class Box {
public Point $mininum;
public Point $maximum;
}

+ 70
- 0
lib/OSM/Element/Element.php View File

@ -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;
}
}

+ 38
- 0
lib/OSM/Element/Member/Member.php View File

@ -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);
}
}

+ 19
- 0
lib/OSM/Element/Member/Node.php View File

@ -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;
}
}

+ 38
- 0
lib/OSM/Element/Member/Way.php View File

@ -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;
}
}

+ 86
- 0
lib/OSM/Element/Relation.php View File

@ -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;
}
}

+ 22
- 0
lib/OSM/Element/Tag.php View File

@ -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;
}
}

+ 25
- 0
lib/OSM/GeoJsonConverter.php View File

@ -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 ]);
}
}

+ 25
- 0
lib/OSM/OSM.php View File

@ -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;
}
}

+ 30
- 0
lib/OSM/Point.php View File

@ -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;
}
}

+ 4
- 0
php.ini View File

@ -0,0 +1,4 @@
upload_max_filesize = 40M
post_max_size = 40M
memory_limit = 256M

+ 6
- 4
src/Controller/TaskController.php View File

@ -112,9 +112,11 @@ class TaskController extends AbstractController
'imagery' => [
'id' => $project->hasImagery() ? $project->getImagery() : 'osmfr',
],
];
if ($task->hasOsm() and !empty($task->getOsm())) {
// Charge le XML OSM du projet danms un calque de données
// TODO C’est susceptible de planter s’il n’y a pas d’OSM dans le projet mais est-ce possible ?
'import' => [
$josmCommands['import'] = [
'new_layer' => true,
'layer_name' => $task->getName(),
'url' => $this->generateUrl(
@ -122,8 +124,8 @@ class TaskController extends AbstractController
['slug' => $task->getSlug()],
UrlGeneratorInterface::ABSOLUTE_URL
),
],
];
];
}
if ($task->hasGeojson()) {
$geom = \geoPHP::load($task->getGeojson(), 'json');


+ 96
- 0
src/Controller/ToolsController.php View File

@ -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,
]);
}
}

+ 5
- 0
src/Entity/Task.php View File

@ -181,6 +181,11 @@ class Task
return $this;
}
public function hasOsm(): bool
{
return isset($this->osm);
}
public function getOsm(): ?string
{
return $this->osm;


+ 56
- 0
src/Form/CityToolType.php View File

@ -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,
])
;
}
}

+ 16
- 0
src/Service/OverpassClient.php View File

@ -29,4 +29,20 @@ class OverpassClient
return $response->getContent();
}
public function rawQuery($query)
{
$response = $this->client->request('GET', 'https://overpass-api.de/api/interpreter', [
'query' => [
'data' => $query,
],
]);
$isStatusCodeOk = (200 === $response->getStatusCode());
if (!$isStatusCodeOk) {
throw new \RuntimeException();
}
return $response->getContent();
}
}

+ 3
- 0
templates/project/show.html.twig View File

@ -96,12 +96,14 @@
<div class="col mb-3">
<p class="text-muted">
{% include 'partials/_project-metadata.html.twig' %}<br/>
{% if project.hashtags is not empty %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hash" viewBox="0 0 16 16">
<path d="M8.39 12.648a1 1 0 0 0-.015.18c0 .305.21.508.5.508.266 0 .492-.172.555-.477l.554-2.703h1.204c.421 0 .617-.234.617-.547 0-.312-.188-.53-.617-.53h-.985l.516-2.524h1.265c.43 0 .618-.227.618-.547 0-.313-.188-.524-.618-.524h-1.046l.476-2.304a1 1 0 0 0 .016-.164.51.51 0 0 0-.516-.516.54.54 0 0 0-.539.43l-.523 2.554H7.617l.477-2.304c.008-.04.015-.118.015-.164a.51.51 0 0 0-.523-.516.54.54 0 0 0-.531.43L6.53 5.484H5.414c-.43 0-.617.22-.617.532s.187.539.617.539h.906l-.515 2.523H4.609c-.421 0-.609.219-.609.531s.188.547.61.547h.976l-.516 2.492c-.008.04-.015.125-.015.18 0 .305.21.508.5.508.265 0 .492-.172.554-.477l.555-2.703h2.242zm-1-6.109h2.266l-.515 2.563H6.859l.532-2.563z"/>
</svg>
{% for hashtag in project.hashtags|split(' ') %}
<a href="https://osmcha.org/?filters=%7B%22metadata%22%3A%5B%7B%22label%22%3A%22hashtags%3D%23{{ hashtag }}%22%2C%22value%22%3A%22hashtags%3D%23{{ hashtag }}%22%7D%5D%7D" target="_blank">{{ '#' ~ hashtag }}</a>
{% endfor %}
{% endif %}
</p>
</div>
</div>
@ -161,6 +163,7 @@
</table>
</div>
</div>
{{ knp_pagination_render(tasks) }}
{% endif %}
{% if comments is not empty %}


+ 14
- 0
templates/tools/base.html.twig View File

@ -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 %}

+ 5
- 0
templates/tools/city.html.twig View File

@ -0,0 +1,5 @@
{% extends 'tools/base.html.twig' %}
{% block tools_content %}
{{ form(form) }}
{% endblock %}

+ 2
- 0
templates/tools/index.html.twig View File

@ -0,0 +1,2 @@
{% extends 'tools/base.html.twig' %}

Loading…
Cancel
Save