Browse Source

ajoute la gestion d’overpass

master
vincent 2 months ago
parent
commit
340ca36d3c
13 changed files with 285 additions and 24 deletions
  1. +52
    -7
      assets/controllers/map_controller.js
  2. +1
    -0
      composer.json
  3. +7
    -7
      composer.lock
  4. +39
    -0
      migrations/Version20240729113155.php
  5. +21
    -0
      src/Controller/MapController.php
  6. +33
    -4
      src/Controller/ProjectController.php
  7. +61
    -0
      src/Entity/Project.php
  8. +16
    -2
      src/Form/ProjectType.php
  9. +32
    -0
      src/Service/OverpassClient.php
  10. +12
    -2
      templates/macro.html.twig
  11. +6
    -0
      templates/partials/_overpass-element-popup.html.twig
  12. +4
    -1
      templates/project/show.html.twig
  13. +1
    -1
      templates/project/update.html.twig

+ 52
- 7
assets/controllers/map_controller.js View File

@ -4,7 +4,9 @@ import 'leaflet';
export default class extends Controller {
static values = {
geojson: String,
overpassResult: String,
icon: String,
popupUrl: String,
}
connect() {
@ -24,9 +26,10 @@ export default class extends Controller {
'danger': L.divIcon({ html: iconHtml, className: 'svg-icon text-danger', iconSize: [16, 16], iconAnchor: [8, 16], }),
'warning': L.divIcon({ html: iconHtml, className: 'svg-icon text-warning', iconSize: [16, 16], iconAnchor: [8, 16], }),
'success': L.divIcon({ html: iconHtml, className: 'svg-icon text-success', iconSize: [16, 16], iconAnchor: [8, 16], }),
'info': L.divIcon({ html: iconHtml, className: 'svg-icon text-info', iconSize: [16, 16], iconAnchor: [8, 16], }),
};
var map = L.map(this.element);
var geojsons, _this = this, map = L.map(this.element);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@ -34,7 +37,40 @@ export default class extends Controller {
var layer = L.featureGroup();
var geojsons = JSON.parse(this.geojsonValue);
var layer2 = L.featureGroup();
if (this.overpassResultValue !== '') {
geojsons = JSON.parse(this.overpassResultValue);
if (geojsons.elements.length > 0) {
geojsons.elements.forEach(function (element) {
element.members.forEach(function (member) {
const polygon = L.polyline(member.geometry.map(function (coord) { return L.latLng(coord.lat, coord.lon)}), {
color: '#0dcaf0',
weight: 6,
opacity: 0.8,
}).addTo(layer2).bindPopup(L.popup({
overpassElement: element,
}).setContent('…'));
});
});
layer2.on('popupopen', function (event) {
var element = event.popup.options.overpassElement;
delete element.members;
fetch(_this.popupUrlValue + '?' + (new URLSearchParams({
'element': JSON.stringify(element),
})))
.then(function (response) {
return response.text();
})
.then(function (text) {
event.popup.setContent(text);
});
});
layer2.addTo(layer);
}
}
var layer1 = L.featureGroup();
geojsons = JSON.parse(this.geojsonValue);
if (geojsons.length > 0) {
geojsons.forEach(function (geojson) {
@ -50,7 +86,7 @@ export default class extends Controller {
}
return {color: color};
}
}).bindTooltip(feature0.name).addTo(layer).on('click', function (event) {
}).bindTooltip(feature0.name).addTo(layer1).on('click', function (event) {
window.location.href = event.layer.feature.properties.url;
});
@ -58,14 +94,23 @@ export default class extends Controller {
icon: icons[feature0.color],
title: feature0.name,
clickUrl: feature0.url,
}).addTo(layer).on('click', function (event) {
}).addTo(layer1).on('click', function (event) {
window.location.href = event.target.options.clickUrl;
});
});
layer.addTo(map);
map.fitBounds(layer.getBounds());
layer1.addTo(layer);
}
layer.addTo(map);
if (this.overpassResultValue !== '') {
L.control.layers({}, {
'Overpass': layer2,
'Tâches': layer1,
}).addTo(map);
}
map.fitBounds(layer.getBounds());
}
}

+ 1
- 0
composer.json View File

@ -26,6 +26,7 @@
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/http-client": "7.1.*",
"symfony/mime": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*",


+ 7
- 7
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": "705ac1a078cbdb34254dbd216eaeb8c4",
"content-hash": "9c359230cb1f5cc5131e4f1fa1e24e1f",
"packages": [
{
"name": "behat/transliterator",
@ -4990,16 +4990,16 @@
},
{
"name": "symfony/http-client",
"version": "v7.1.2",
"version": "v7.1.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "90ace27d17ccc9afc6f7ec0081e8529fb0e29425"
"reference": "b79858aa7a051ea791b0d50269a234a0b50cb231"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/90ace27d17ccc9afc6f7ec0081e8529fb0e29425",
"reference": "90ace27d17ccc9afc6f7ec0081e8529fb0e29425",
"url": "https://api.github.com/repos/symfony/http-client/zipball/b79858aa7a051ea791b0d50269a234a0b50cb231",
"reference": "b79858aa7a051ea791b0d50269a234a0b50cb231",
"shasum": ""
},
"require": {
@ -5064,7 +5064,7 @@
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.1.2"
"source": "https://github.com/symfony/http-client/tree/v7.1.3"
},
"funding": [
{
@ -5080,7 +5080,7 @@
"type": "tidelift"
}
],
"time": "2024-06-28T08:00:31+00:00"
"time": "2024-07-17T06:10:24+00:00"
},
{
"name": "symfony/http-client-contracts",


+ 39
- 0
migrations/Version20240729113155.php View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240729113155 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE project ADD COLUMN overpass_query CLOB DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD COLUMN overpass_result CLOB DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__project AS SELECT id, created_by_id, name, description, slug, created_at, hashtags, source, imagery FROM project');
$this->addSql('DROP TABLE project');
$this->addSql('CREATE TABLE project (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL, slug VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, hashtags VARCHAR(255) DEFAULT NULL, source VARCHAR(255) DEFAULT NULL, imagery VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_2FB3D0EEB03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO project (id, created_by_id, name, description, slug, created_at, hashtags, source, imagery) SELECT id, created_by_id, name, description, slug, created_at, hashtags, source, imagery FROM __temp__project');
$this->addSql('DROP TABLE __temp__project');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2FB3D0EE989D9B62 ON project (slug)');
$this->addSql('CREATE INDEX IDX_2FB3D0EEB03A8386 ON project (created_by_id)');
}
}

+ 21
- 0
src/Controller/MapController.php View File

@ -0,0 +1,21 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/map')]
class MapController extends AbstractController
{
#[Route('/popup', name: 'app_map_popup')]
public function popup(Request $request): Response
{
$element = json_decode($request->query->get('element'), true);
return $this->render('partials/_overpass-element-popup.html.twig', [
'element' => $element,
]);
}
}

+ 33
- 4
src/Controller/ProjectController.php View File

@ -7,6 +7,7 @@ use App\Entity\Project;
use App\Entity\Task;
use App\Form\CsvType;
use App\Form\ProjectType;
use App\Service\OverpassClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -194,10 +195,7 @@ class ProjectController extends AbstractController
$project = $repository->findOneBySlug($slug);
if (!$project) {
$this->addFlash(
'warning',
'Projet non trouvé !'
);
$this->addFlash('warning', 'Projet non trouvé !');
return $this->redirectToRoute('app_project');
}
@ -217,4 +215,35 @@ class ProjectController extends AbstractController
return $this->redirectToRoute('app_project');
}
#[Route('/{slug}/overpass', name: 'app_project_overpass')]
public function overpass(OverpassClient $overpassClient, EntityManagerInterface $entityManager, $slug): Response
{
$repository = $entityManager->getRepository(Project::class);
$project = $repository->findOneBySlug($slug);
if (!$project) {
$this->addFlash('warning', 'Projet non trouvé !');
return $this->redirectToRoute('app_project');
}
if (!$project->hasOverpassQuery()) {
$this->addFlash('warning', 'Ce projet n’a pas de requête Overpass !');
return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]);
}
if ($project->hasOverpassResult() and !$project->isOverpassResultOutdated()) {
$this->addFlash('warning', 'Merci d’attendre un peu avant de requêter de nouveau Overpass !');
return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]);
}
$result = $overpassClient->query($project->getOverpassQuery());
$project->setOverpassResult($result);
$entityManager->persist($project);
$entityManager->flush();
return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]);
}
}

+ 61
- 0
src/Entity/Project.php View File

@ -56,6 +56,12 @@ class Project
#[ORM\Column(length: 255, nullable: true)]
private ?string $imagery = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $overpassQuery = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $overpassResult = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@ -210,4 +216,59 @@ class Project
return $this;
}
public function getOverpassQuery(): ?string
{
return $this->overpassQuery;
}
public function setOverpassQuery(?string $overpassQuery): static
{
$this->overpassQuery = $overpassQuery;
return $this;
}
public function hasOverpassQuery(): bool
{
return is_string($this->overpassQuery) and !empty(trim($this->overpassQuery));
}
public function getOverpassResult(): ?string
{
return $this->overpassResult;
}
public function setOverpassResult(?string $overpassResult): static
{
$this->overpassResult = $overpassResult;
return $this;
}
public function hasOverpassResult(): bool
{
return is_string($this->overpassResult) and !empty(trim($this->overpassResult));
}
public function isOverpassResultOutdated(): bool
{
$data = json_decode($this->overpassResult, true);
if (is_null($data)) {
throw new \RuntimeException();
}
if (!isset($data['osm3s']['timestamp_osm_base'])) {
throw new \RuntimeException();
}
$generatedAt = new \DateTimeImmutable($data['osm3s']['timestamp_osm_base']);
$now = new \DateTimeImmutable('now');
$minimum = new \DateInterval('PT1H');
$isOutdated = ($generatedAt->add($minimum) < $now);
return $isOutdated;
}
}

+ 16
- 2
src/Form/ProjectType.php View File

@ -16,13 +16,27 @@ class ProjectType extends AbstractType
$builder
->add('name', null, ['label' => 'Nom'])
->add('description', null, ['label' => 'Description'])
->add('hashtags', null, ['label' => 'Hashtags'])
->add('source', null, ['label' => 'Source'])
->add('hashtags', null, [
'label' => 'Hashtags',
'help_html' => true,
'help' => 'Mots séparés par des espaces, sans <samp>#</samp>',
])
->add('source', null, [
'label' => 'Source',
'help_html' => true,
'help' => 'On peut en préciser plusieurs, en les séparant par des <samp>;</samp>',
])
->add('imagery', null, [
'label' => 'Imagerie',
'help_html' => true,
'help' => 'Cf <a href="https://josm.openstreetmap.de/wiki/Maps" target="_blank">le wiki</a>',
])
->add('overpassQuery', null, [
'label' => 'Requête Overpass',
'required' => false,
'help_html' => true,
'help' => 'Cf <a href="https://overpass-turbo.eu/" target="_blank">le frontal</a>. Se limiter à la requête stricto sensu, sans <samp>[out:json][timeout:25];</samp> avant ou <samp>out geom;</samp> après.',
])
->add('tags', EntityType::class, [
'label' => 'Étiquettes',
'class' => Tag::class,


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

@ -0,0 +1,32 @@
<?php
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class OverpassClient {
public function __construct(
private HttpClientInterface $client,
) {
}
public function query($query, $prefix = '[out:json][timeout:25];', $suffix = 'out geom;')
{
$response = $this->client->request('GET', 'https://overpass-api.de/api/interpreter', [
'query' => [
'data' => $prefix . $query . $suffix,
],
]);
$isStatusCodeOk = ($response->getStatusCode() === 200);
$isContentTypeJson = ($response->getHeaders()['content-type'][0] === 'application/json');
if (!$isStatusCodeOk or !$isContentTypeJson) {
throw new \RuntimeException();
}
return $response->getContent();
}
}

+ 12
- 2
templates/macro.html.twig View File

@ -19,6 +19,16 @@
{% endif %}
{% endmacro %}
{% macro map(entity) %}
<div id="map" class="img-fluid img-thumbnail min-vh-50" data-controller="map" data-map-geojson-value="{{ geoJsonManager.generateGeoJson(entity)|json_encode }}" data-map-icon-value="{{ asset('images/marker.svg') }}"></div>
{% macro map(entity, overpassResult='') %}
<div
id="map"
class="img-fluid img-thumbnail min-vh-50"
data-controller="map"
data-map-geojson-value="{{ geoJsonManager.generateGeoJson(entity)|json_encode }}"
{% if overpassResult is not empty %}
data-map-overpass-result-value="{{ overpassResult }}"
data-map-popup-url-value={{ url('app_map_popup') }}
{% endif %}
data-map-icon-value="{{ asset('images/marker.svg') }}"
></div>
{% endmacro %}

+ 6
- 0
templates/partials/_overpass-element-popup.html.twig View File

@ -0,0 +1,6 @@
<h6>{{ element.type|capitalize }} {{ element.id }}</h6>
<p>Voir sur <a href="https://www.openstreetmap.org/{{ element.type }}/{{ element.id }}" target="_blank">OSM</a>
{% if element.type == 'relation' and element.tags and element.tags.route and element.tags.route == 'hiking' %}
<br/>sur <a href="https://hiking.waymarkedtrails.org/#route?id={{ element.id }}&type=relation" target="_blank">WayMarkedTrails</a>
{% endif %}
</p>

+ 4
- 1
templates/project/show.html.twig View File

@ -23,6 +23,9 @@
<a href="{{ path('app_project_update', {'slug': project.slug}) }}" class="btn btn-secondary">Modifier le projet</a>
<a href="{{ path('app_project_remove', {'slug': project.slug}) }}" class="btn btn-secondary">Supprimer le projet</a>
{% endif %}
{% if project.overpassQuery %}
<a href="{{ path('app_project_overpass', {'slug': project.slug}) }}" class="btn btn-secondary">Requêter Overpass</a>
{% endif %}
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#modal">Importer des tâches</button>
<a href="{{ path('app_task_create', {'slug': project.slug}) }}" class="btn btn-secondary">Créer une tâche</a>
{% endif %}
@ -49,7 +52,7 @@
<h2 class="mb-3">Carte</h2>
<div class="row">
<div class="col mb-3">
{{ macro.map(project) }}
{{ macro.map(project, project.overpassResult ? project.overpassResult : '') }}
</div>
</div>
{% endif %}


+ 1
- 1
templates/project/update.html.twig View File

@ -2,7 +2,7 @@
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ path('app_project') }}">Projets</a></li>
<li class="breadcrumb-item"><a href="{{ path('app_project_create') }}">{{ project.name }}</a></li>
<li class="breadcrumb-item"><a href="{{ path('app_project_show', {'slug': project.slug}) }}">{{ project.name }}</a></li>
{% endblock %}
{% block page_title %}Modifier le projet{% endblock %}


Loading…
Cancel
Save