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 %}
-
+ |
{{ 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 @@
-
-
Statistiques par ville
@@ -115,12 +129,32 @@
{% for stat in stats %}
- {{ stat.name }} |
+
+ {{ stat.name }}
+ |
{{ stat.zone }} |
{{ stat.completionPercent }}% |
{{ stat.places|length }} |
- Voir les statistiques
+
|
{% 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 %}
-
-
-
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 %}
+
|