diff --git a/assets/controllers/map_controller.js b/assets/controllers/map_controller.js index e8df3c4..2214b14 100644 --- a/assets/controllers/map_controller.js +++ b/assets/controllers/map_controller.js @@ -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: '© OpenStreetMap' @@ -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()); } } diff --git a/composer.json b/composer.json index 0b73479..8981bef 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index d508f53..0b60c04 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/migrations/Version20240729113155.php b/migrations/Version20240729113155.php new file mode 100644 index 0000000..a77b21d --- /dev/null +++ b/migrations/Version20240729113155.php @@ -0,0 +1,39 @@ +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)'); + } +} diff --git a/src/Controller/MapController.php b/src/Controller/MapController.php new file mode 100644 index 0000000..522fdd4 --- /dev/null +++ b/src/Controller/MapController.php @@ -0,0 +1,21 @@ +query->get('element'), true); + return $this->render('partials/_overpass-element-popup.html.twig', [ + 'element' => $element, + ]); + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 4986737..d1bf253 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -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()]); + } + } diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 85f99f9..d5fcf33 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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; + } + } diff --git a/src/Form/ProjectType.php b/src/Form/ProjectType.php index 550817b..e4423df 100644 --- a/src/Form/ProjectType.php +++ b/src/Form/ProjectType.php @@ -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 #', + ]) + ->add('source', null, [ + 'label' => 'Source', + 'help_html' => true, + 'help' => 'On peut en préciser plusieurs, en les séparant par des ;', + ]) ->add('imagery', null, [ 'label' => 'Imagerie', 'help_html' => true, 'help' => 'Cf le wiki', ]) + ->add('overpassQuery', null, [ + 'label' => 'Requête Overpass', + 'required' => false, + 'help_html' => true, + 'help' => 'Cf le frontal. Se limiter à la requête stricto sensu, sans [out:json][timeout:25]; avant ou out geom; après.', + ]) ->add('tags', EntityType::class, [ 'label' => 'Étiquettes', 'class' => Tag::class, diff --git a/src/Service/OverpassClient.php b/src/Service/OverpassClient.php new file mode 100644 index 0000000..17c3afa --- /dev/null +++ b/src/Service/OverpassClient.php @@ -0,0 +1,32 @@ +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(); + } + +} diff --git a/templates/macro.html.twig b/templates/macro.html.twig index 8614126..684efc4 100644 --- a/templates/macro.html.twig +++ b/templates/macro.html.twig @@ -19,6 +19,16 @@ {% endif %} {% endmacro %} -{% macro map(entity) %} -
+{% macro map(entity, overpassResult='') %} +
{% endmacro %} diff --git a/templates/partials/_overpass-element-popup.html.twig b/templates/partials/_overpass-element-popup.html.twig new file mode 100644 index 0000000..b8697cf --- /dev/null +++ b/templates/partials/_overpass-element-popup.html.twig @@ -0,0 +1,6 @@ +
{{ element.type|capitalize }} {{ element.id }}
+

Voir sur OSM +{% if element.type == 'relation' and element.tags and element.tags.route and element.tags.route == 'hiking' %} +
sur WayMarkedTrails +{% endif %} +

diff --git a/templates/project/show.html.twig b/templates/project/show.html.twig index e6bf6b8..3222a66 100644 --- a/templates/project/show.html.twig +++ b/templates/project/show.html.twig @@ -23,6 +23,9 @@ Modifier le projet Supprimer le projet {% endif %} + {% if project.overpassQuery %} + Requêter Overpass + {% endif %} Créer une tâche {% endif %} @@ -49,7 +52,7 @@

Carte

- {{ macro.map(project) }} + {{ macro.map(project, project.overpassResult ? project.overpassResult : '') }}
{% endif %} diff --git a/templates/project/update.html.twig b/templates/project/update.html.twig index 3b7a3c4..0f77d16 100644 --- a/templates/project/update.html.twig +++ b/templates/project/update.html.twig @@ -2,7 +2,7 @@ {% block breadcrumb %} - + {% endblock %} {% block page_title %}Modifier le projet{% endblock %}