diff --git a/assets/utils.js b/assets/utils.js index 7358a94..8b692b7 100644 --- a/assets/utils.js +++ b/assets/utils.js @@ -1,4 +1,3 @@ - function colorHeadingTable() { const headers = document.querySelectorAll('th'); @@ -21,7 +20,7 @@ function check_validity(e) { 'input[name="commerce_tag_value__contact:email"]', 'input[name="commerce_tag_value__contact:phone"]', 'input[name="commerce_tag_value__contact:website"]', - 'commerce_tag_value__contact:mastodon', + 'input[name="commerce_tag_value__contact:mastodon"]', 'input[name="commerce_tag_value__address"]', 'input[name="custom_opening_hours"]', 'input[name="commerce_tag_value__contact:street"]', diff --git a/labourage.sh b/labourage.sh index cc51777..59dca32 100644 --- a/labourage.sh +++ b/labourage.sh @@ -12,6 +12,18 @@ codes_insee=( "33063" # Bordeaux "59350" # Lille "35238" # Rennes + "75101" # Paris 1er + "75102" # Paris 2e + "75103" # Paris 3e + "75104" # Paris 4e + "75105" # Paris 5e + "75106" # Paris 6e + "75107" # Paris 7e + "75108" # Paris 8e + "75109" # Paris 9e + "75110" # Paris 10e + "75111" # Paris 11e + "75112" # Paris 12e "75113" # Paris 13e "75114" # Paris 14e "75115" # Paris 15e diff --git a/migrations/Version20250617141249.php b/migrations/Version20250617141249.php new file mode 100644 index 0000000..006f42b --- /dev/null +++ b/migrations/Version20250617141249.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE place ADD habitants INT DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE place DROP habitants + SQL); + } +} diff --git a/migrations/Version20250617141824.php b/migrations/Version20250617141824.php new file mode 100644 index 0000000..c6d01f3 --- /dev/null +++ b/migrations/Version20250617141824.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE stats ADD population INT DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE stats DROP population + SQL); + } +} diff --git a/public/assets/img/josm.png b/public/assets/img/josm.png new file mode 100644 index 0000000..1764904 Binary files /dev/null and b/public/assets/img/josm.png differ diff --git a/public/assets/img/logo-osm.png b/public/assets/img/logo-osm.png new file mode 100644 index 0000000..4388167 Binary files /dev/null and b/public/assets/img/logo-osm.png differ diff --git a/public/assets/img/osm-id.png b/public/assets/img/osm-id.png new file mode 100644 index 0000000..2ea1914 Binary files /dev/null and b/public/assets/img/osm-id.png differ diff --git a/public/js/utils.js b/public/js/utils.js index cfa61a7..bc42570 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -1,98 +1,68 @@ // Fonction pour gérer la recherche de villes -function setupCitySearch(searchInputId, suggestionListId, onCitySelected) { - const searchInput = document.getElementById(searchInputId); +/** + * Configure la recherche de ville avec autocomplétion + * @param {string} inputId - ID de l'input de recherche + * @param {string} suggestionListId - ID de la liste des suggestions + * @param {Function} onSelect - Callback appelé lors de la sélection d'une ville + */ +export function setupCitySearch(inputId, suggestionListId, onSelect) { + const searchInput = document.getElementById(inputId); const suggestionList = document.getElementById(suggestionListId); - // Vérifier si les éléments existent avant de continuer - if (!searchInput || !suggestionList) { - console.debug(`Éléments de recherche non trouvés: ${searchInputId}, ${suggestionListId}`); - return; - } + if (!searchInput || !suggestionList) return; - let searchTimeout; + let timeoutId = null; - // Fonction pour nettoyer la liste des suggestions - function clearSuggestions() { - suggestionList.innerHTML = ''; - suggestionList.style.display = 'none'; - } + searchInput.addEventListener('input', function () { + clearTimeout(timeoutId); + const query = this.value.trim(); - // Fonction pour afficher les suggestions - function displaySuggestions(suggestions) { - clearSuggestions(); - if (suggestions.length === 0) { - return; - } - - suggestions.forEach(suggestion => { - const item = document.createElement('div'); - item.className = 'suggestion-item'; - item.innerHTML = ` -
${suggestion.display_name}
-
- ${suggestion.type} - ${suggestion.postcode || ''} -
- `; - - item.addEventListener('click', () => { - searchInput.value = suggestion.display_name; - clearSuggestions(); - if (onCitySelected) { - onCitySelected(suggestion); - } - }); - - suggestionList.appendChild(item); - }); - - suggestionList.style.display = 'block'; - } - - // Fonction pour effectuer la recherche - async function performSearch(query) { - if (!query || query.length < 3) { + if (query.length < 2) { clearSuggestions(); return; } - try { - // Recherche avec Nominatim - const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&countrycodes=fr&limit=5`); - const data = await response.json(); - - // Filtrer pour ne garder que les villes - const citySuggestions = data.filter(item => - item.type === 'city' || - item.type === 'town' || - item.type === 'village' || - item.type === 'administrative' - ); - - displaySuggestions(citySuggestions); - } catch (error) { - console.error('Erreur lors de la recherche:', error); - clearSuggestions(); - } - } - - // Gestionnaire d'événements pour l'input - searchInput.addEventListener('input', (e) => { - const query = e.target.value.trim(); - - // Annuler la recherche précédente - if (searchTimeout) { - clearTimeout(searchTimeout); - } - - // Attendre 300ms après la dernière frappe avant de lancer la recherche - searchTimeout = setTimeout(() => { - performSearch(query); - }, 300); + timeoutId = setTimeout(() => performSearch(query), 300); }); - // Fermer la liste des suggestions en cliquant en dehors - document.addEventListener('click', (e) => { + function performSearch(query) { + fetch(`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(query)}&fields=nom,code,codesPostaux&limit=5`) + .then(response => response.json()) + .then(data => { + const citySuggestions = data.map(city => ({ + name: city.nom, + postcode: city.codesPostaux[0], + insee: city.code + })); + displaySuggestions(citySuggestions); + }) + .catch(error => { + console.error('Erreur lors de la recherche:', error); + clearSuggestions(); + }); + } + + function displaySuggestions(suggestions) { + clearSuggestions(); + suggestions.forEach(suggestion => { + const li = document.createElement('li'); + li.className = 'list-group-item'; + li.textContent = `${suggestion.name} (${suggestion.postcode})`; + li.addEventListener('click', () => { + searchInput.value = suggestion.name; + clearSuggestions(); + if (onSelect) onSelect(suggestion); + }); + suggestionList.appendChild(li); + }); + } + + function clearSuggestions() { + suggestionList.innerHTML = ''; + } + + // Fermer les suggestions en cliquant en dehors + document.addEventListener('click', function (e) { if (!searchInput.contains(e.target) && !suggestionList.contains(e.target)) { clearSuggestions(); } @@ -100,17 +70,97 @@ function setupCitySearch(searchInputId, suggestionListId, onCitySelected) { } // Fonction pour formater l'URL de labourage -function getLabourerUrl(zipCode) { +/** + * Génère l'URL de labourage pour un code postal donné + * @param {string} zipCode - Le code postal + * @returns {string} L'URL de labourage + */ +export function getLabourerUrl(zipCode) { return `/admin/labourer/${zipCode}`; } // Fonction pour gérer la soumission du formulaire d'ajout de ville -window.handleAddCityFormSubmit = function(event) { +export function handleAddCityFormSubmit(event) { event.preventDefault(); const form = event.target; - const zipCode = form.querySelector('input[name="zip_code"]').value; - - if (zipCode) { - window.location.href = getLabourerUrl(zipCode); + const submitButton = form.querySelector('button[type="submit"]'); + const zipCodeInput = form.querySelector('input[name="zip_code"]'); + if (!zipCodeInput.value) { + return; } -}; \ No newline at end of file + // Afficher le spinner + submitButton.disabled = true; + const originalContent = submitButton.innerHTML; + submitButton.innerHTML = ' Labourer...'; + // Rediriger + window.location.href = getLabourerUrl(zipCodeInput.value); +} + +/** + * Colore les cellules d'un tableau en fonction des pourcentages + * @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer + * @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50') + */ +export function colorizePercentageCells(selector, color = '154, 205, 50') { + document.querySelectorAll(selector).forEach(cell => { + const percentage = parseInt(cell.textContent); + if (!isNaN(percentage)) { + const alpha = percentage / 100; + cell.style.backgroundColor = `rgba(${color}, ${alpha})`; + } + }); +} + +/** + * Colore les cellules d'un tableau avec un gradient relatif à la valeur maximale + * @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer + * @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50') + */ +export function colorizePercentageCellsRelative(selector, color = '154, 205, 50') { + // Récupérer toutes les cellules + const cells = document.querySelectorAll(selector); + + // Trouver la valeur maximale + let maxValue = 0; + cells.forEach(cell => { + const value = parseInt(cell.textContent); + if (!isNaN(value) && value > maxValue) { + maxValue = value; + } + }); + + // Appliquer le gradient relatif à la valeur max + cells.forEach(cell => { + const value = parseInt(cell.textContent); + if (!isNaN(value)) { + const alpha = value / maxValue; // Ratio relatif au maximum + cell.style.backgroundColor = `rgba(${color}, ${alpha})`; + } + }); +} + +/** + * Ajuste dynamiquement la taille du texte des éléments list-group-item selon leur nombre + * @param {string} selector - Le sélecteur CSS des éléments à ajuster + * @param {number} [minFont=0.8] - Taille de police minimale en rem + * @param {number} [maxFont=1.2] - Taille de police maximale en rem + */ +export function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) { + const items = document.querySelectorAll(selector); + const count = items.length; + let fontSize = maxFont; + if (count > 0) { + // Plus il y a d'items, plus la taille diminue, mais jamais en dessous de minFont + fontSize = Math.max(minFont, maxFont - (count - 5) * 0.05); + } + items.forEach(item => { + item.style.fontSize = fontSize + 'rem'; + }); +} + +function check_validity() { + if (!document.getElementById('editLand')) { + return; + } + // ... suite du code ... +} \ No newline at end of file diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 6c74ab3..0b3b30f 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -131,10 +131,8 @@ final class AdminController extends AbstractController // Récupérer ou créer les stats pour cette zone $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]); - $city = $this->motocultrice->get_city_osm_from_zip_code($zip_code); if (!$stats) { - $stats = new Stats(); $stats->setZone($zip_code) ->setPlacesCount(0) @@ -144,11 +142,27 @@ final class AdminController extends AbstractController ->setAvecAccessibilite(0) ->setAvecNote(0) ->setCompletionPercent(0); - $this->entityManager->persist($stats); - $this->entityManager->flush(); - } + $this->entityManager->persist($stats); + $this->entityManager->flush(); + } $stats->setName($city); + // Récupérer la population via l'API + $population = null; + try { + $apiUrl = 'https://geo.api.gouv.fr/communes/' . $zip_code . '?fields=population'; + $response = file_get_contents($apiUrl); + if ($response !== false) { + $data = json_decode($response, true); + if (isset($data['population'])) { + $population = (int)$data['population']; + $stats->setPopulation($population); + } + } + } catch (\Exception $e) { + // Ne rien faire si l'API échoue + } + // Récupérer toutes les données $places = $this->motocultrice->labourer($zip_code); $processedCount = 0; diff --git a/src/Entity/Place.php b/src/Entity/Place.php index 1ceb91b..a3d3328 100644 --- a/src/Entity/Place.php +++ b/src/Entity/Place.php @@ -100,6 +100,9 @@ class Place #[ORM\Column(length: 255, nullable: true)] private ?string $siret = null; + #[ORM\Column(nullable: true)] + private ?int $habitants = null; + public function getMainTag(): ?string { return $this->main_tag; @@ -579,4 +582,16 @@ class Place return $this; } + + public function getHabitants(): ?int + { + return $this->habitants; + } + + public function setHabitants(?int $habitants): static + { + $this->habitants = $habitants; + + return $this; + } } diff --git a/src/Entity/Stats.php b/src/Entity/Stats.php index 57a06d3..6db0de4 100644 --- a/src/Entity/Stats.php +++ b/src/Entity/Stats.php @@ -55,6 +55,10 @@ class Stats #[ORM\Column(length: 255, nullable: true)] private ?string $name = null; + // nombre d'habitants dans la zone + #[ORM\Column(type: Types::INTEGER, nullable: true)] + private ?int $population = null; + // calcule le pourcentage de complétion de la zone public function computeCompletionPercent(): ?int { @@ -255,6 +259,17 @@ class Stats return $this; } + + public function getPopulation(): ?int + { + return $this->population; + } + + public function setPopulation(?int $population): static + { + $this->population = $population; + return $this; + } } diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index ac20d2b..a4cf711 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -37,6 +37,27 @@ + {% if stats.population %} +
+
+ + Population : {{ stats.population|number_format(0, '.', ' ') }} + +
+
+ + 1 lieu pour + {% set ratio = (stats.population and stats.places|length > 0) ? (stats.population / stats.places|length)|round(0, 'ceil') : '?' %} + {{ ratio|number_format(0, '.', ' ') }} habitants + +
+
+ + {{ stats.getAvecNote() }} / {{ stats.places|length }} commerces avec note + +
+
+ {% endif %}
@@ -198,28 +219,52 @@ function calculateCompletion(element) { let completionCount = 0; let totalFields = 0; + let missingFields = []; const fieldsToCheck = [ - 'name', - 'contact:street', - 'contact:housenumber', - 'opening_hours', - 'contact:website', - 'contact:phone', - 'wheelchair' + {name: 'name', label: 'Nom du commerce'}, + {name: 'contact:street', label: 'Rue'}, + {name: 'contact:housenumber', label: 'Numéro'}, + {name: 'opening_hours', label: 'Horaires d\'ouverture'}, + {name: 'contact:website', label: 'Site web'}, + {name: 'contact:phone', label: 'Téléphone'}, + {name: 'wheelchair', label: 'Accessibilité PMR'} ]; fieldsToCheck.forEach(field => { totalFields++; - if (element.tags && element.tags[field]) { + if (element.tags && element.tags[field.name]) { completionCount++; + } else { + missingFields.push(field.label); } }); - return (completionCount / totalFields) * 100; + return { + percentage: (completionCount / totalFields) * 100, + missingFields: missingFields + }; + } + + function showMissingFieldsPopup(element) { + const completion = calculateCompletion(element); + if (completion.percentage < 100) { + const popup = new maplibregl.Popup() + .setLngLat(element.geometry.coordinates) + .setHTML(` +
+
Informations manquantes pour ${element.tags?.name || 'ce commerce'}
+
    + ${completion.missingFields.map(field => `
  • ${field}
  • `).join('')} +
+
+ `); + popup.addTo(map); + } } function createPopupContent(element) { + const completion = calculateCompletion(element); let content = ` `; + if (completion.percentage < 100) { + content += ` +
+
Informations manquantes :
+
    + ${completion.missingFields.map(field => `
  • ${field}
  • `).join('')} +
+
+ `; + } + content += ''; // Ajouter tous les tags @@ -250,7 +306,7 @@ elements.forEach(element => { const completion = calculateCompletion(element); - const bucketIndex = Math.floor(completion / 10); + const bucketIndex = Math.floor(completion.percentage / 10); buckets[bucketIndex]++; }); @@ -356,7 +412,7 @@ properties: { id: element.id, name: element.tags?.name || 'Sans nom', - completion: completion, + completion: completion.percentage, center: [lon, lat] }, geometry: { @@ -400,86 +456,33 @@ function updateMarkers() { // Supprimer tous les marqueurs existants - features.forEach(feature => { - const layerId = `marker-${feature.properties.id}`; - // Supprimer d'abord la couche - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - // Puis supprimer la source - if (map.getSource(layerId)) { - map.removeSource(layerId); - } - }); - - // Supprimer tous les marqueurs en goutte existants dropMarkers.forEach(marker => marker.remove()); dropMarkers = []; - if (currentMarkerType === 'circle') { - // Afficher les cercles - features.forEach(feature => { - const layerId = `marker-${feature.properties.id}`; - const circle = turf.circle( - feature.properties.center, - 5/1000, - { steps: 64, units: 'kilometers' } - ); + features.forEach(feature => { + const el = document.createElement('div'); + el.className = 'marker'; + el.style.backgroundColor = getCompletionColor(feature.properties.completion); + el.style.width = '15px'; + el.style.height = '15px'; + el.style.borderRadius = '50%'; + el.style.border = '2px solid white'; + el.style.cursor = 'pointer'; - map.addSource(layerId, { - 'type': 'geojson', - 'data': circle - }); + const marker = new maplibregl.Marker(el) + .setLngLat(feature.geometry.coordinates) + .addTo(map); - map.addLayer({ - 'id': layerId, - 'type': 'fill', - 'source': layerId, - 'paint': { - 'fill-color': getCompletionColor(feature.properties.completion), - 'fill-opacity': 0.7 - } - }); - }); - - // Ajouter les popups sur les cercles - map.on('click', function(e) { - const clickedFeatures = map.queryRenderedFeatures(e.point, { - layers: features.map(f => `marker-${f.properties.id}`) - }); - - if (clickedFeatures.length > 0) { - const feature = clickedFeatures[0]; - const elementId = feature.layer.id.replace('marker-', ''); - const element = overpassData[elementId]; - - if (element) { - // Créer le contenu de la popup - const popupContent = createPopupContent(element); - - new maplibregl.Popup() - .setLngLat(e.lngLat) - .setHTML(popupContent) - .addTo(map); - } + // Ajouter l'événement de clic + el.addEventListener('click', () => { + const element = overpassData[feature.properties.id]; + if (element) { + showMissingFieldsPopup(element); } }); - } else { - // Afficher les marqueurs en goutte - features.forEach(feature => { - const element = overpassData[feature.properties.id]; - const popupContent = element ? createPopupContent(element) : `

${feature.properties.name}

`; - - const marker = new maplibregl.Marker({ - color: getCompletionColor(feature.properties.completion) - }) - .setLngLat(feature.properties.center) - .setPopup(new maplibregl.Popup({ offset: 25 }) - .setHTML(popupContent)) - .addTo(map); - dropMarkers.push(marker); - }); - } + + dropMarkers.push(marker); + }); } function draw_circle_containing_all_features(map) { @@ -722,6 +725,40 @@ }); sortTable(); + + // Initialiser les popovers pour les cellules de complétion + const completionCells = document.querySelectorAll('.completion-cell'); + completionCells.forEach(cell => { + new bootstrap.Popover(cell, { + trigger: 'hover', + html: true + }); + + // Fermer tous les popovers au clic sur une cellule + cell.addEventListener('click', function(e) { + e.stopPropagation(); + completionCells.forEach(otherCell => { + if (otherCell !== cell) { + const popover = bootstrap.Popover.getInstance(otherCell); + if (popover) { + popover.hide(); + } + } + }); + }); + }); + + // Fermer tous les popovers quand on clique ailleurs + document.addEventListener('click', function(e) { + if (!e.target.closest('.completion-cell')) { + completionCells.forEach(cell => { + const popover = bootstrap.Popover.getInstance(cell); + if (popover) { + popover.hide(); + } + }); + } + }); }); function toggleCompletionInfo() { diff --git a/templates/admin/stats/row.html.twig b/templates/admin/stats/row.html.twig index dba5d08..4e351a4 100644 --- a/templates/admin/stats/row.html.twig +++ b/templates/admin/stats/row.html.twig @@ -10,7 +10,37 @@ {% endif %} - {% for stat in stats %} - + {% endfor %} @@ -129,5 +163,35 @@ + +
+
+
+
+

Labourer une ville

+

+ + Rechercher une ville pour labourer ses commerces +

+
+
+ +
+
+ + + +
+
+
+
{% endblock %} diff --git a/templates/public/edit.html.twig b/templates/public/edit.html.twig index 3077d1e..a205bfb 100644 --- a/templates/public/edit.html.twig +++ b/templates/public/edit.html.twig @@ -4,7 +4,7 @@ {% block body %} -
+
@@ -15,7 +15,7 @@ {% if commerce_overpass is not empty %}
- {# nom #} +
@@ -86,13 +86,10 @@
- - {% endif %}
-
@@ -126,17 +123,20 @@ {{ 'display.by'|trans }} {{ commerce_overpass['@attributes'].user }}
- -
{{ 'display.view_on_osm'|trans }} + {{ asset('img/logo-osm.png') }} - -
diff --git a/templates/public/home.html.twig b/templates/public/home.html.twig index d711a3c..0f5a149 100644 --- a/templates/public/home.html.twig +++ b/templates/public/home.html.twig @@ -55,6 +55,14 @@ position: relative; margin-bottom: 1rem; } + .list-group-item{ + cursor: pointer; + + } + .list-group-item:hover{ + background-color: #f5f5f5; + color: #000; + } {% endblock %} @@ -94,29 +102,24 @@
-

Villes disponibles

-

Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre ville grâce à OpenStreetMap

+

Villes disponibles

+

Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre ville grâce à OpenStreetMap

+
+ + {% for stat in stats %} + +
+ {{ stat.zone }} + {{ stat.name }}
-
-
-
-
- Ajoutez votre ville - -
-
-
-
+ {{ stat.placesCount }} lieux + + {% endfor %} + + + +
{% endblock %} +{% block javascripts %} + {{ parent() }} + +{% endblock %} +
+ {{ commerce.getCompletionPercentage() }} diff --git a/templates/public/dashboard.html.twig b/templates/public/dashboard.html.twig index 35f6a52..b1a21ce 100644 --- a/templates/public/dashboard.html.twig +++ b/templates/public/dashboard.html.twig @@ -53,9 +53,21 @@ {% block javascripts %} {{ parent() }} - - {% endblock %} @@ -77,28 +113,6 @@
-
-
-
-

Labourer une ville

-
-
- -
-
- - -
-
-
-
-
- -

Statistiques par ville

@@ -115,12 +129,32 @@
{{ stat.name }} + {{ stat.name }} + {{ stat.zone }} {{ stat.completionPercent }}% {{ stat.places|length }} - Voir les statistiques +