diff --git a/assets/app.js b/assets/app.js index 3abd89a3..52a290f0 100644 --- a/assets/app.js +++ b/assets/app.js @@ -17,7 +17,107 @@ import './josm.js'; import './edit.js'; import Chart from 'chart.js/auto'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { genererCouleurPastel, setupCitySearch, handleAddCityFormSubmit, enableLabourageForm, getLabourerUrl, adjustListGroupFontSize } from './utils.js'; +import Tablesort from 'tablesort'; + window.Chart = Chart; +window.genererCouleurPastel = genererCouleurPastel; +window.setupCitySearch = setupCitySearch; +window.handleAddCityFormSubmit = handleAddCityFormSubmit; +window.getLabourerUrl = getLabourerUrl; + +Chart.register(ChartDataLabels); + +function initDashboardChart() { + const chartCanvas = document.getElementById('statsBubbleChart'); + if (!chartCanvas) { + return; + } + + if (!chartCanvas.dataset.stats || chartCanvas.dataset.stats.length <= 2) { // <= 2 pour ignorer '[]' + console.log("Les données du graphique sont vides ou absentes, le graphique ne sera pas affiché."); + return; + } + + const statsData = JSON.parse(chartCanvas.dataset.stats); + + if (statsData && statsData.length > 0) { + const bubbleChartData = statsData.map(stat => { + const population = parseInt(stat.population, 10); + const placesCount = parseInt(stat.placesCount, 10); + const ratio = population > 0 ? (placesCount / population) * 1000 : 0; + + return { + x: population, + y: ratio, + r: Math.sqrt(placesCount) * 2, + label: stat.name, + completion: stat.completionPercent || 0 + }; + }); + + new Chart(chartCanvas.getContext('2d'), { + type: 'bubble', + data: { + datasets: [{ + label: 'Villes', + data: bubbleChartData, + backgroundColor: bubbleChartData.map(d => `rgba(255, 99, 132, ${d.completion / 100})`), + borderColor: 'rgba(255, 99, 132, 1)', + }] + }, + options: { + responsive: true, + plugins: { + datalabels: { + anchor: 'center', + align: 'center', + color: '#000', + font: { + weight: 'bold' + }, + formatter: (value, context) => { + return context.dataset.data[context.dataIndex].label; + } + }, + legend: { + display: false + }, + tooltip: { + callbacks: { + label: (context) => { + const d = context.raw; + return [ + `${d.label}`, + `Population: ${d.x.toLocaleString()}`, + `Lieux / 1000 hab: ${d.y.toFixed(2)}`, + `Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`, + `Complétion: ${d.completion}%` + ]; + } + } + } + }, + scales: { + x: { + type: 'logarithmic', + title: { + display: true, + text: 'Population (échelle log)' + } + }, + y: { + title: { + display: true, + text: 'Commerces pour 1000 habitants' + } + } + } + } + }); + } +} // Attendre le chargement du DOM document.addEventListener('DOMContentLoaded', () => { @@ -79,6 +179,11 @@ document.addEventListener('DOMContentLoaded', () => { } updateCompletionProgress(); + // Activer le tri sur tous les tableaux désignés + document.querySelectorAll('.js-sort-table').forEach(table => { + new Tablesort(table); + }); + // Focus sur le premier champ texte au chargement const firstTextInput = document.querySelector('input.form-control'); if (firstTextInput) { @@ -94,10 +199,10 @@ document.addEventListener('DOMContentLoaded', () => { parseCuisine(); // Tri automatique des tableaux - const tables = document.querySelectorAll('table'); - tables.forEach(table => { - table.classList.add('js-sort-table'); - }); + // const tables = document.querySelectorAll('table'); + // tables.forEach(table => { + // table.classList.add('js-sort-table'); + // }); // Modifier la fonction de recherche existante @@ -160,4 +265,9 @@ document.addEventListener('DOMContentLoaded', () => { }, 300); }); } + + enableLabourageForm(); + initDashboardChart(); + + adjustListGroupFontSize('.list-group-item'); }); diff --git a/assets/utils.js b/assets/utils.js index 8215bba0..83a2029f 100644 --- a/assets/utils.js +++ b/assets/utils.js @@ -71,7 +71,7 @@ function check_validity(e) { } } -const genererCouleurPastel = () => { +export const genererCouleurPastel = () => { const r = Math.floor(Math.random() * 75 + 180); const g = Math.floor(Math.random() * 75 + 180); const b = Math.floor(Math.random() * 75 + 180); @@ -163,23 +163,26 @@ function openInPanoramax() { window.open(panoramaxUrl); } -function enableLabourageForm() { +export function enableLabourageForm() { const citySearchInput = document.getElementById('citySearch'); const citySuggestionsList = document.getElementById('citySuggestions'); if (citySearchInput && citySuggestionsList) { + const form = citySearchInput.closest('form'); setupCitySearch('citySearch', 'citySuggestions', function (result_search) { - const labourageBtn = document.querySelector('.btn-labourer'); - if (labourageBtn) { - labourageBtn.innerHTML = ' Chargement...'; - labourageBtn.disabled = true; + if (form) { + const labourageBtn = form.querySelector('.btn-labourer'); + if (labourageBtn) { + labourageBtn.innerHTML = ' Chargement...'; + labourageBtn.disabled = true; + } } window.location.href = getLabourerUrl(result_search); }); } } -function setupCitySearch(inputId, suggestionListId, onSelect) { +export function setupCitySearch(inputId, suggestionListId, onSelect) { const searchInput = document.getElementById(inputId); const suggestionList = document.getElementById(suggestionListId); @@ -251,14 +254,14 @@ function setupCitySearch(inputId, suggestionListId, onSelect) { }); } -function getLabourerUrl(obj) { +export function getLabourerUrl(obj) { if (obj && obj.insee) { - return `/admin/labourer_insee/${obj.insee}`; + return `/admin/labourer/${obj.insee}`; } return '#'; } -function handleAddCityFormSubmit(event) { +export function handleAddCityFormSubmit(event) { event.preventDefault(); const zipCode = document.getElementById('selectedZipCode').value; if (zipCode && zipCode.match(/^\d{5}$/)) { @@ -268,9 +271,9 @@ function handleAddCityFormSubmit(event) { } } -function colorizePercentageCells(selector, color = '154, 205, 50') { +export function colorizePercentageCells(selector, color = '154, 205, 50') { document.querySelectorAll(selector).forEach(cell => { - const percentage = parseInt(cell.textContent); + const percentage = parseInt(cell.textContent.replace('%', ''), 10); if (!isNaN(percentage)) { const alpha = percentage / 100; cell.style.backgroundColor = `rgba(${color}, ${alpha})`; @@ -278,45 +281,66 @@ function colorizePercentageCells(selector, color = '154, 205, 50') { }); } -function colorizePercentageCellsRelative(selector, color = '154, 205, 50') { +export function colorizePercentageCellsRelative(selector, color = '154, 205, 50') { + let min = Infinity; + let max = -Infinity; const cells = document.querySelectorAll(selector); - let maxValue = 0; cells.forEach(cell => { - const value = parseInt(cell.textContent); - if (!isNaN(value) && value > maxValue) { - maxValue = value; + const value = parseInt(cell.textContent.replace('%', ''), 10); + if (!isNaN(value)) { + min = Math.min(min, value); + max = Math.max(max, value); } }); - cells.forEach(cell => { - const value = parseInt(cell.textContent); - if (!isNaN(value)) { - const alpha = value / maxValue; - cell.style.backgroundColor = `rgba(${color}, ${alpha})`; - } - }); + if (max > min) { + cells.forEach(cell => { + const value = parseInt(cell.textContent.replace('%', ''), 10); + if (!isNaN(value)) { + const ratio = (value - min) / (max - min); + cell.style.backgroundColor = `rgba(${color}, ${ratio.toFixed(2)})`; + } + }); + } } -function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) { - const items = document.querySelectorAll(selector); - const count = items.length; +export function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) { + const listItems = document.querySelectorAll(selector); + if (listItems.length === 0) return; + let fontSize = maxFont; + const count = listItems.length; if (count > 0) { fontSize = Math.max(minFont, maxFont - (count - 5) * 0.05); } - items.forEach(item => { + listItems.forEach(item => { item.style.fontSize = fontSize + 'rem'; }); } -window.setupCitySearch = setupCitySearch; -window.handleAddCityFormSubmit = handleAddCityFormSubmit; -window.colorizePercentageCells = colorizePercentageCells; -window.colorizePercentageCellsRelative = colorizePercentageCellsRelative; +export function calculateCompletion(properties) { + let completed = 0; + const total = 7; // Nombre de critères + + if (properties.name) completed++; + if (properties['addr:housenumber'] && properties['addr:street']) completed++; + if (properties.opening_hours) completed++; + if (properties.website || properties['contact:website']) completed++; + if (properties.phone || properties['contact:phone']) completed++; + if (properties.wheelchair) completed++; + if (properties.note) completed++; + + return { + percentage: total > 0 ? (completed / total) * 100 : 0, + completed: completed, + total: total + }; +} + window.check_validity = check_validity; +window.colorHeadingTable = colorHeadingTable; window.openInPanoramax = openInPanoramax; window.listChangesets = listChangesets; -window.colorHeadingTable = colorHeadingTable; -window.adjustListGroupFontSize = adjustListGroupFontSize; -window.genererCouleurPastel = genererCouleurPastel; \ No newline at end of file +window.adjustListGroupFontSize = adjustListGroupFontSize; +window.calculateCompletion = calculateCompletion; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5695a9b4..e2adc6da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "@babel/preset-env": "^7.16.0", "@symfony/webpack-encore": "^5.0.0", "chart.js": "^4.5.0", + "chartjs-plugin-datalabels": "^2.2.0", "core-js": "^3.38.0", "regenerator-runtime": "^0.13.9", + "tablesort": "^5.6.0", "webpack": "^5.74.0", "webpack-cli": "^5.1.0" } @@ -2490,6 +2492,16 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -5099,6 +5111,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/tablesort": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/tablesort/-/tablesort-5.6.0.tgz", + "integrity": "sha512-cZZXK3G089PbpxH8N7vN7Z21SEKqXAaCiSVOmZdR/v7z8TFCsF/OFr0rzjhQuFlQQHy9uQtW9P2oQFJzJFGVrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16", + "npm": ">= 8" + } + }, "node_modules/tapable": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", diff --git a/package.json b/package.json index bfe5fab1..e902c31b 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,10 @@ "@babel/preset-env": "^7.16.0", "@symfony/webpack-encore": "^5.0.0", "chart.js": "^4.5.0", + "chartjs-plugin-datalabels": "^2.2.0", "core-js": "^3.38.0", "regenerator-runtime": "^0.13.9", + "tablesort": "^5.6.0", "webpack": "^5.74.0", "webpack-cli": "^5.1.0" }, diff --git a/public/js/sort-table/sort-table.css b/public/js/sort-table/sort-table.css deleted file mode 100644 index bb60756a..00000000 --- a/public/js/sort-table/sort-table.css +++ /dev/null @@ -1,35 +0,0 @@ -/* These style add the up/down arrows for the current sorted column - * To support more columns, just augment these styles in like form. - */ -table.js-sort-asc.js-sort-0 thead tr:last-child > :nth-child(1):after, -table.js-sort-asc.js-sort-1 thead tr:last-child > :nth-child(2):after, -table.js-sort-asc.js-sort-2 thead tr:last-child > :nth-child(3):after, -table.js-sort-asc.js-sort-3 thead tr:last-child > :nth-child(4):after, -table.js-sort-asc.js-sort-4 thead tr:last-child > :nth-child(5):after, -table.js-sort-asc.js-sort-5 thead tr:last-child > :nth-child(6):after, -table.js-sort-asc.js-sort-6 thead tr:last-child > :nth-child(7):after, -table.js-sort-asc.js-sort-7 thead tr:last-child > :nth-child(8):after, -table.js-sort-asc.js-sort-8 thead tr:last-child > :nth-child(9):after, -table.js-sort-asc.js-sort-9 thead tr:last-child > :nth-child(10):after -{ - content: "\25b2"; - font-size: 0.7em; - padding-left: 3px; - line-height: 0.7em; -} -table.js-sort-desc.js-sort-0 thead tr:last-child > :nth-child(1):after, -table.js-sort-desc.js-sort-1 thead tr:last-child > :nth-child(2):after, -table.js-sort-desc.js-sort-2 thead tr:last-child > :nth-child(3):after, -table.js-sort-desc.js-sort-3 thead tr:last-child > :nth-child(4):after, -table.js-sort-desc.js-sort-4 thead tr:last-child > :nth-child(5):after, -table.js-sort-desc.js-sort-5 thead tr:last-child > :nth-child(6):after, -table.js-sort-desc.js-sort-6 thead tr:last-child > :nth-child(7):after, -table.js-sort-desc.js-sort-7 thead tr:last-child > :nth-child(8):after, -table.js-sort-desc.js-sort-8 thead tr:last-child > :nth-child(9):after, -table.js-sort-desc.js-sort-9 thead tr:last-child > :nth-child(10):after -{ - content: "\25bc"; - font-size: 0.7em; - padding-left: 3px; - line-height: 0.7em; -} diff --git a/public/js/sort-table/sort-table.js b/public/js/sort-table/sort-table.js deleted file mode 100644 index fbd01b33..00000000 --- a/public/js/sort-table/sort-table.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * sort-table.js - * A pure JavaScript (no dependencies) solution to make HTML - * Tables sortable - * - * Copyright (c) 2013 Tyler Uebele - * Released under the MIT license. See included LICENSE.txt - * or http://opensource.org/licenses/MIT - * - * latest version available at https://github.com/tyleruebele/sort-table - */ - -/** - * Sort the rows in a HTML Table - * - * @param Table The Table DOM object - * @param col The zero-based column number by which to sort - * @param dir Optional. The sort direction; pass 1 for asc; -1 for desc - * @returns void - */ -function sortTable(Table, col, dir) { - var sortClass, i; - - if (!Table) { - return; - } - // get previous sort column - sortTable.sortCol = -1; - sortClass = Table.className.match(/js-sort-\d+/); - if (null != sortClass) { - sortTable.sortCol = sortClass[0].replace(/js-sort-/, ''); - Table.className = Table.className.replace(new RegExp(' ?' + sortClass[0] + '\\b'), ''); - } - // If sort column was not passed, use previous - if ('undefined' === typeof col) { - col = sortTable.sortCol; - } - - if ('undefined' !== typeof dir) { - // Accept -1 or 'desc' for descending. All else is ascending - sortTable.sortDir = dir == -1 || dir == 'desc' ? -1 : 1; - } else { - // sort direction was not passed, use opposite of previous - sortClass = Table.className.match(/js-sort-(a|de)sc/); - if (null != sortClass && sortTable.sortCol == col) { - sortTable.sortDir = 'js-sort-asc' == sortClass[0] ? -1 : 1; - } else { - sortTable.sortDir = 1; - } - } - Table.className = Table.className.replace(/ ?js-sort-(a|de)sc/g, ''); - - // update sort column - Table.className += ' js-sort-' + col; - sortTable.sortCol = col; - - // update sort direction - Table.className += ' js-sort-' + (sortTable.sortDir == -1 ? 'desc' : 'asc'); - - // get sort type - if (col < Table.tHead.rows[Table.tHead.rows.length - 1].cells.length) { - sortClass = Table.tHead.rows[Table.tHead.rows.length - 1].cells[col].className.match(/js-sort-[-\w]+/); - } - // Improved support for colspan'd headers - for (i = 0; i < Table.tHead.rows[Table.tHead.rows.length - 1].cells.length; i++) { - if (col == Table.tHead.rows[Table.tHead.rows.length - 1].cells[i].getAttribute('data-js-sort-colNum')) { - sortClass = Table.tHead.rows[Table.tHead.rows.length - 1].cells[i].className.match(/js-sort-[-\w]+/); - } - } - if (null != sortClass) { - sortTable.sortFunc = sortClass[0].replace(/js-sort-/, ''); - } else { - sortTable.sortFunc = 'string'; - } - - // sort! - var rows = [], - TBody = Table.tBodies[0]; - - for (i = 0; i < TBody.rows.length; i++) { - rows[i] = TBody.rows[i]; - } - rows.sort(sortTable.compareRow); - - while (TBody.firstChild) { - TBody.removeChild(TBody.firstChild); - } - for (i = 0; i < rows.length; i++) { - TBody.appendChild(rows[i]); - } -} - -/** - * Compare two table rows based on current settings - * - * @param RowA A TR DOM object - * @param RowB A TR DOM object - * @returns {number} 1 if RowA is greater, -1 if RowB, 0 if equal - */ -sortTable.compareRow = function (RowA, RowB) { - var valA, valB; - if ('function' != typeof sortTable[sortTable.sortFunc]) { - sortTable.sortFunc = 'string'; - } - valA = sortTable[sortTable.sortFunc](RowA.cells[sortTable.sortCol]); - valB = sortTable[sortTable.sortFunc](RowB.cells[sortTable.sortCol]); - - return valA == valB ? 0 : sortTable.sortDir * (valA > valB ? 1 : -1); -}; - -/** - * Strip all HTML, no exceptions - * @param html - * @returns {string} - */ -sortTable.stripTags = function (html) { - return html.replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, ''); -}; - -/** - * Helper function that converts a table cell (TD) to a comparable value - * Converts innerHTML to a JS Date object - * - * @param Cell A TD DOM object - * @returns {Date} - */ -sortTable.date = function (Cell) { - return new Date(sortTable.stripTags(Cell.innerHTML)); -}; - -/** - * Helper function that converts a table cell (TD) to a comparable value - * Converts innerHTML to a JS Number object - * - * @param Cell A TD DOM object - * @returns {Number} - */ -sortTable.number = function (Cell) { - return Number(sortTable.stripTags(Cell.innerHTML).replace(/[^-\d.]/g, '')); -}; - -/** - * Helper function that converts a table cell (TD) to a comparable value - * Converts innerHTML to a lower case string for insensitive compare - * - * @param Cell A TD DOM object - * @returns {String} - */ -sortTable.string = function (Cell) { - return sortTable.stripTags(Cell.innerHTML).toLowerCase(); -}; - -/** - * Helper function that converts a table cell (TD) to a comparable value - * Captures the last space-delimited token from innerHTML - * - * @param Cell A TD DOM object - * @returns {String} - */ -sortTable.last = function (Cell) { - return sortTable.stripTags(Cell.innerHTML).split(' ').pop().toLowerCase(); -}; - -/** - * Helper function that converts a table cell (TD) to a comparable value - * Captures the value of the first childNode - * - * @param Cell A TD DOM object - * @returns {String} - */ -sortTable.input = function (Cell) { - for (var i = 0; i < Cell.children.length; i++) { - if ('object' == typeof Cell.children[i] - && 'undefined' != typeof Cell.children[i].value - ) { - return Cell.children[i].value.toLowerCase(); - } - } - - return sortTable.string(Cell); -}; - -/** - * Return the click handler appropriate to the specified Table and column - * - * @param Table Table to sort - * @param col Column to sort by - * @returns {Function} Click Handler - */ -sortTable.getClickHandler = function (Table, col) { - return function () { - sortTable(Table, col); - }; -}; - -/** - * Attach sortTable() calls to table header cells' onclick events - * If the table(s) do not have a THead node, one will be created around the - * first row - */ -sortTable.init = function () { - var THead, Tables, Handler; - if (document.querySelectorAll) { - Tables = document.querySelectorAll('table.js-sort-table'); - } else { - Tables = document.getElementsByTagName('table'); - } - - for (var i = 0; i < Tables.length; i++) { - // Because IE<8 doesn't support querySelectorAll, skip unclassed tables - if (!document.querySelectorAll && null === Tables[i].className.match(/\bjs-sort-table\b/)) { - continue; - } - - // Prevent repeat processing - if (Tables[i].attributes['data-js-sort-table']) { - continue; - } - - // Ensure table has a tHead element - if (!Tables[i].tHead) { - THead = document.createElement('thead'); - THead.appendChild(Tables[i].rows[0]); - Tables[i].insertBefore(THead, Tables[i].children[0]); - } else { - THead = Tables[i].tHead; - } - - // Attach click events to table header - for (var rowNum = 0; rowNum < THead.rows.length; rowNum++) { - for (var cellNum = 0, colNum = 0; cellNum < THead.rows[rowNum].cells.length; cellNum++) { - // Define which column the header should invoke sorting for - THead.rows[rowNum].cells[cellNum].setAttribute('data-js-sort-colNum', colNum); - Handler = sortTable.getClickHandler(Tables[i], colNum); - window.addEventListener - ? THead.rows[rowNum].cells[cellNum].addEventListener('click', Handler) - : window.attachEvent && THead.rows[rowNum].cells[cellNum].attachEvent('onclick', Handler); - colNum += THead.rows[rowNum].cells[cellNum].colSpan; - } - } - - // Mark table as processed - Tables[i].setAttribute('data-js-sort-table', 'true') - } -}; - -// Run sortTable.init() when the page loads -window.addEventListener - ? window.addEventListener('load', sortTable.init, false) - : window.attachEvent && window.attachEvent('onload', sortTable.init) - ; diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index e4128db9..0515eb92 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -11,6 +11,7 @@ use App\Entity\Stats; use App\Entity\StatsHistory; use App\Service\Motocultrice; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\Request; use function uuid_create; final class AdminController extends AbstractController @@ -234,102 +235,18 @@ final class AdminController extends AbstractController public function calculer_stats(string $insee_code): Response { // Récupérer tous les commerces de la zone - $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); + $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code, 'dead' => false]); // Récupérer les stats existantes pour la zone $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); if(!$stats) { + // Si aucune stat n'existe, on en crée une vide pour éviter les erreurs, mais sans la sauvegarder $stats = new Stats(); $stats->setZone($insee_code); + $stats->setName('Nouvelle zone non labourée'); } - $urls = $stats->getAllCTCUrlsMap(); - - // Calculer les statistiques - $calculatedStats = $this->motocultrice->calculateStats($commerces); - - // Mettre à jour les stats pour la zone donnée - $stats->setPlacesCount($calculatedStats['places_count']); - $stats->setAvecHoraires($calculatedStats['counters']['avec_horaires']); - $stats->setAvecAdresse($calculatedStats['counters']['avec_adresse']); - $stats->setAvecSite($calculatedStats['counters']['avec_site']); - $stats->setAvecAccessibilite($calculatedStats['counters']['avec_accessibilite']); - $stats->setAvecNote($calculatedStats['counters']['avec_note']); - $stats->setCompletionPercent($calculatedStats['completion_percent']); - - // Associer les stats à chaque commerce - foreach ($commerces as $commerce) { - $commerce->setStats($stats); - $this->entityManager->persist($commerce); - } - - $this->entityManager->persist($stats); - $this->entityManager->flush(); - - $stats->computeCompletionPercent(); - - // Calculer les statistiques de fraîcheur des données OSM - $timestamps = []; - foreach ($stats->getPlaces() as $place) { - if ($place->getOsmDataDate()) { - $timestamps[] = $place->getOsmDataDate()->getTimestamp(); - } - } - - if (!empty($timestamps)) { - // Date la plus ancienne (min) - $minTimestamp = min($timestamps); - $stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp)); - - // Date la plus récente (max) - $maxTimestamp = max($timestamps); - $stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp)); - - // Date moyenne - $avgTimestamp = array_sum($timestamps) / count($timestamps); - $stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp)); - } - - if($stats->getDateCreated() == null) { - $stats->setDateCreated(new \DateTime()); - } - - $stats->setDateModified(new \DateTime()); - - // Créer un historique des statistiques - $statsHistory = new StatsHistory(); - $statsHistory->setDate(new \DateTime()) - ->setStats($stats); - - // Compter les Places avec email et SIRET - $placesWithEmail = 0; - $placesWithSiret = 0; - foreach ($stats->getPlaces() as $place) { - if ($place->getEmail() && $place->getEmail() !== '') { - $placesWithEmail++; - } - if ($place->getSiret() && $place->getSiret() !== '') { - $placesWithSiret++; - } - } - - $statsHistory->setPlacesCount($stats->getPlaces()->count()) - ->setOpeningHoursCount($stats->getAvecHoraires()) - ->setAddressCount($stats->getAvecAdresse()) - ->setWebsiteCount($stats->getAvecSite()) - ->setSiretCount($placesWithSiret) - ->setEmailsCount($placesWithEmail) - // ->setAccessibiliteCount($stats->getAvecAccessibilite()) - // ->setNoteCount($stats->getAvecNote()) - ->setCompletionPercent($stats->getCompletionPercent()) - ->setStats($stats); - - $this->entityManager->persist($statsHistory); - - - $this->entityManager->persist($stats); - $this->entityManager->flush(); - + $urls = $stats->getAllCTCUrlsMap(); $statsHistory = $this->entityManager->getRepository(StatsHistory::class) ->createQueryBuilder('sh') ->where('sh.stats = :stats') @@ -339,13 +256,54 @@ final class AdminController extends AbstractController ->getQuery() ->getResult(); + /* + // La page de statistiques ne doit pas modifier les données, seulement les afficher. + // La mise à jour des statistiques se fait lors du labourage. + + // Calculer les statistiques + $calculatedStats = $this->motocultrice->calculateStats($commerces); + + // Mettre à jour les stats pour la zone donnée + $stats->setPlacesCount($calculatedStats['places_count']); + // ... (plus de setters) ... + $stats->setCompletionPercent($calculatedStats['completion_percent']); + + // ... (boucle foreach sur commerces) ... + + $stats->computeCompletionPercent(); + + $this->entityManager->persist($stats); + $this->entityManager->flush(); + */ + + // Données pour le graphique des modifications par trimestre + $modificationsByQuarter = []; + foreach ($stats->getPlaces() as $commerce) { + if ($commerce->getOsmDataDate()) { + $date = $commerce->getOsmDataDate(); + $year = $date->format('Y'); + $quarter = ceil($date->format('n') / 3); + $key = $year . '-Q' . $quarter; + if (!isset($modificationsByQuarter[$key])) { + $modificationsByQuarter[$key] = 0; + } + $modificationsByQuarter[$key]++; + } + } + ksort($modificationsByQuarter); // Trier par clé (année-trimestre) + + $overpass_query = $this->motocultrice->get_query_places($insee_code); + $overpass_query_url = "https://overpass-turbo.eu/?Q=" . urlencode($overpass_query); + return $this->render('admin/stats.html.twig', [ 'stats' => $stats, - 'insee_code' => $insee_code, - 'query_places' => $this->motocultrice->get_query_places($insee_code), - 'counters' => $calculatedStats['counters'], + 'commerces' => $commerces, + 'urls' => $urls, + 'query_places' => $overpass_query, + 'overpass_query' => $overpass_query, + 'overpass_query_url' => $overpass_query_url, + 'modificationsByQuarter' => json_encode($modificationsByQuarter), 'maptiler_token' => $_ENV['MAPTILER_TOKEN'], - 'mapbox_token' => $_ENV['MAPBOX_TOKEN'], 'statsHistory' => $statsHistory, 'CTC_urls' => $urls, ]); @@ -396,8 +354,9 @@ final class AdminController extends AbstractController * récupérer les commerces de la zone selon le code INSEE, créer les nouveaux lieux, et mettre à jour les existants */ #[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')] - public function labourer(string $insee_code, bool $updateExisting = true): Response + public function labourer(Request $request, string $insee_code, bool $updateExisting = true): Response { + $deleteMissing = $request->query->getBoolean('deleteMissing', true); // Vérifier si le code INSEE est valide (composé uniquement de chiffres) if (!ctype_digit($insee_code) || $insee_code == 'undefined' || $insee_code == '') { @@ -503,12 +462,14 @@ final class AdminController extends AbstractController } } - // Supprimer les lieux qui ne sont plus dans la réponse Overpass - $db_places = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); - foreach ($db_places as $db_place) { - if (!in_array($db_place->getOsmId(), $overpass_osm_ids)) { - $this->entityManager->remove($db_place); - $deletedCount++; + // Supprimer les lieux qui ne sont plus dans la réponse Overpass, si activé + if ($deleteMissing) { + $db_places = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); + foreach ($db_places as $db_place) { + if (!in_array($db_place->getOsmId(), $overpass_osm_ids)) { + $this->entityManager->remove($db_place); + $deletedCount++; + } } } @@ -652,16 +613,35 @@ final class AdminController extends AbstractController #[Route('/admin/delete_by_zone/{insee_code}', name: 'app_admin_delete_by_zone')] public function delete_by_zone(string $insee_code): Response { - $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); - - foreach ($commerces as $commerce) { - $this->entityManager->remove($commerce); - } - $this->entityManager->remove($stats); - $this->entityManager->flush(); - $this->addFlash('success', 'Tous les commerces de la zone '.$insee_code.' ont été supprimés avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.'); + if (!$stats) { + $this->addFlash('error', 'Aucune statistique trouvée pour la zone ' . $insee_code); + return $this->redirectToRoute('app_public_dashboard'); + } + + try { + // 1. Supprimer tous les StatsHistory associés + foreach ($stats->getStatsHistories() as $history) { + $this->entityManager->remove($history); + } + + // 2. Supprimer tous les Places associées + foreach ($stats->getPlaces() as $place) { + $this->entityManager->remove($place); + } + + // 3. Supprimer l'objet Stats lui-même + $this->entityManager->remove($stats); + + // 4. Appliquer les changements à la base de données + $this->entityManager->flush(); + + $this->addFlash('success', 'La zone ' . $insee_code . ' et toutes les données associées ont été supprimées avec succès.'); + + } catch (\Exception $e) { + $this->addFlash('error', 'Une erreur est survenue lors de la suppression de la zone ' . $insee_code . ': ' . $e->getMessage()); + } return $this->redirectToRoute('app_public_dashboard'); } diff --git a/src/Controller/PublicController.php b/src/Controller/PublicController.php index 593c5602..cb89a3c9 100644 --- a/src/Controller/PublicController.php +++ b/src/Controller/PublicController.php @@ -173,17 +173,28 @@ class PublicController extends AbstractController public function dashboard(): Response { - $stats = $this->entityManager->getRepository(Stats::class)->findAll(); + $stats_repo = $this->entityManager->getRepository(Stats::class)->findAll(); + $stats_for_chart = []; + foreach ($stats_repo as $stat) { + if ($stat->getPlacesCount() > 0 && $stat->getName() !== null && $stat->getPopulation() > 0) { + $stats_for_chart[] = [ + 'name' => $stat->getName(), + 'placesCount' => $stat->getPlacesCount(), + 'completionPercent' => $stat->getCompletionPercent(), + 'population' => $stat->getPopulation(), + ]; + } + } // Compter le nombre total de lieux $placesCount = $this->entityManager->getRepository(Place::class)->count([]); return $this->render('public/dashboard.html.twig', [ 'controller_name' => 'PublicController', - 'mapbox_token' => $_ENV['MAPBOX_TOKEN'], - 'maptiler_token' => $_ENV['MAPTILER_TOKEN'], - 'stats' => $stats, - + 'mapbox_token' => $_ENV['MAPBOX_TOKEN'] ?? null, + 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, + 'stats' => json_encode($stats_for_chart), + 'stats_list' => $stats_repo, 'places_count' => $placesCount, ]); } diff --git a/src/Service/Motocultrice.php b/src/Service/Motocultrice.php index c72c0997..11ef3203 100644 --- a/src/Service/Motocultrice.php +++ b/src/Service/Motocultrice.php @@ -3,8 +3,7 @@ namespace App\Service; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Doctrine\ORM\EntityManagerInterface; - +use Doctrine\ORM\EntityManagerInterface; class Motocultrice { private $overpassApiUrl = 'https://overpass-api.de/api/interpreter'; diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index 7418afc8..488e1aa1 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -54,7 +54,7 @@ {{ stats.name }} - {{ stats.completionPercent }}% complété
- Labourer les mises à jour + Labourer les mises à jour @@ -83,7 +83,7 @@ {% endif %} {# Affichage de la fraîcheur des données OSM #} - {% if stats.osmDataDateMin and stats.osmDataDateMax and stats.osmDataDateAvg %} + {# {% if stats.osmDataDateMin and stats.osmDataDateMax and stats.osmDataDateAvg %} {% set now = "now"|date("U") %} {% set minDate = stats.osmDataDateMin|date("U") %} {% set maxDate = stats.osmDataDateMax|date("U") %} @@ -147,7 +147,7 @@
- {% endif %} + {% endif %} #}
@@ -249,14 +249,14 @@

Requête Overpass

-
-            {{query_places|raw}}
-            
- +
+            {{ overpass_query|raw }}
+    
+
Exécuter dans Overpass Turbo -
-
+ +

Historique des {{ statsHistory|length }} stats

@@ -305,18 +305,36 @@ + +
+
+
+
+ Fréquence des mises à jour (par trimestre) +
+
+ +
+
+
+
+ +
+
+

+

@@ -326,9 +344,11 @@ {% block javascripts %} {{ parent() }} - + - @@ -494,12 +570,12 @@ let contextMenu = null; // Menu contextuel -function calculateCompletion(element) { - let completionCount = 0; - let totalFields = 0; + function calculateCompletion(element) { + let completionCount = 0; + let totalFields = 0; let missingFields = []; - - const fieldsToCheck = [ + + const fieldsToCheck = [ { name: 'name', label: 'Nom du commerce' }, { name: 'contact:street', label: 'Rue' }, { name: 'contact:housenumber', label: 'Numéro' }, @@ -507,12 +583,12 @@ function calculateCompletion(element) { { name: 'contact:website', label: 'Site web' }, { name: 'contact:phone', label: 'Téléphone' }, { name: 'wheelchair', label: 'Accessibilité PMR' } - ]; + ]; - fieldsToCheck.forEach(field => { - totalFields++; + fieldsToCheck.forEach(field => { + totalFields++; if (element.tags && element.tags[field.name]) { - completionCount++; + completionCount++; } else { missingFields.push(field.label); } @@ -537,10 +613,10 @@ function getCompletionColor(completion) { } -function createPopupContent(element) { + function createPopupContent(element) { const completion = calculateCompletion(element); - let content = ` -
+ let content = ` + - `; - +
+ `; + if (completion.percentage < 100) { content += `
@@ -565,17 +641,17 @@ function createPopupContent(element) { } content += '
'; + + // Ajouter tous les tags + if (element.tags) { + for (const tag in element.tags) { + content += ``; + } + } - // Ajouter tous les tags - if (element.tags) { - for (const tag in element.tags) { - content += ``; + content += '
${tag}${element.tags[tag]}
${tag}${element.tags[tag]}
'; + return content; } - } - - content += ''; - return content; -} function updateMarkers(features, map, currentMarkerType, dropMarkers, overpassData) { // Supprimer tous les marqueurs existants @@ -822,7 +898,7 @@ window.updateMarkers = updateMarkers; const tempLink = document.createElement('a'); tempLink.style.display = 'none'; document.body.appendChild(tempLink); - + tempLink.href = josmUrl; tempLink.click(); document.body.removeChild(tempLink); @@ -908,7 +984,7 @@ window.updateMarkers = updateMarkers; document.getElementById('openInJOSM').addEventListener('click', openInJOSM); // Attendre que la carte soit chargée avant d'ajouter les écouteurs d'événements - map.on('load', function() { + map.on('load', function() { map_is_loaded = true; // Changer le curseur au survol des marqueurs map.on('mouseenter', function(e) { @@ -984,6 +1060,7 @@ window.updateMarkers = updateMarkers; icon.classList.add('bi-chevron-down'); } } + window.toggleCompletionInfo = toggleCompletionInfo; // infos depuis complète tes commerces : CTC @@ -1102,7 +1179,6 @@ function makeDonutGraphOfTags() { labels: { boxWidth: 15, padding: 15, - font: { size: 12 } } @@ -1115,10 +1191,11 @@ function makeDonutGraphOfTags() { } } } - } - }); -} + }) + }; + makeDonutGraphOfTags(); + markClosedSiretsOnTable(); -{% endblock %} +{% endblock %} diff --git a/templates/admin/stats/row.html.twig b/templates/admin/stats/row.html.twig index 89103453..7774e71d 100644 --- a/templates/admin/stats/row.html.twig +++ b/templates/admin/stats/row.html.twig @@ -117,8 +117,8 @@
+ {{ commerce.osmDataDate|date('Y-m-d H:i') }} - {{ commerce.osmDataDate|date('d/m/Y H:i') }}
{% if commerce.osmUser %}
diff --git a/templates/public/dashboard.html.twig b/templates/public/dashboard.html.twig index b266bac6..9f661b4c 100644 --- a/templates/public/dashboard.html.twig +++ b/templates/public/dashboard.html.twig @@ -66,7 +66,7 @@ Statistiques des villes (nombre de commerces)
- +
@@ -110,7 +110,7 @@ - {% for stat in stats %} + {% for stat in stats_list %} {{ stat.name }} @@ -121,8 +121,8 @@ {{ stat.zone }} {{ stat.completionPercent }}% - {{ stat.placesCount }} - {{ (stat.placesCount / (stat.population or 1 ))|round(2) }} + {{ stat.places|length }} + {{ (stat.places|length / (stat.population or 1 ))|round(2) }}
@@ -158,113 +158,5 @@ {% block javascripts %} {{ parent() }} - {# Les scripts sont maintenant gérés par Webpack Encore via app.js #} - + {# Le script du graphique est maintenant dans assets/app.js #} {% endblock %} \ No newline at end of file