up stats par rue et dans le temps

This commit is contained in:
Tykayn 2025-07-12 12:03:40 +02:00 committed by tykayn
parent cd6c14c378
commit 7355600e6b
8 changed files with 409 additions and 5 deletions

View file

@ -716,4 +716,128 @@ class PublicController extends AbstractController
{
return $this->render('public/faq.html.twig');
}
#[Route('/ville/{cityId}/rue/{streetName}', name: 'app_public_street')]
public function streetView(string $cityId, string $streetName): Response
{
$cityStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $cityId]);
if (!$cityStats) {
throw $this->createNotFoundException('Ville non trouvée');
}
// Décodage du nom de la rue depuis l'URL
$streetName = urldecode($streetName);
// On récupère tous les lieux dont le stats.zone correspond à cityId et la rue correspondante
$places = $this->entityManager->getRepository(Place::class)->createQueryBuilder('p')
->leftJoin('p.stats', 's')
->where('s.zone = :cityId')
->andWhere('p.street = :streetName')
->setParameter('cityId', $cityId)
->setParameter('streetName', $streetName)
->getQuery()
->getResult();
// Conversion des entités Place en tableau associatif pour le JS
$placesArray = array_map(fn($place) => $place->toArray(), $places);
// Préparer la répartition de complétion pour le graphe
$completionBuckets = [
'0-20%' => 0,
'20-40%' => 0,
'40-60%' => 0,
'60-80%' => 0,
'80-100%' => 0
];
foreach ($places as $place) {
$c = $place->getCompletionPercentage();
if ($c < 20) $completionBuckets['0-20%']++;
elseif ($c < 40) $completionBuckets['20-40%']++;
elseif ($c < 60) $completionBuckets['40-60%']++;
elseif ($c < 80) $completionBuckets['60-80%']++;
else $completionBuckets['80-100%']++;
}
return $this->render('public/street.html.twig', [
'city' => $cityStats->getName(),
'city_id' => $cityId,
'street' => $streetName,
'places' => $places, // objets Place pour le HTML
'places_js' => $placesArray, // pour le JS si besoin
'completion_buckets' => $completionBuckets,
'completion_buckets_values' => array_values($completionBuckets),
'stats_url' => $this->generateUrl('app_admin_stats', ['insee_code' => $cityId]),
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null
]);
}
#[Route('/stats/{insee_code}/evolutions', name: 'app_public_stats_evolutions')]
public function statsEvolutions(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
throw $this->createNotFoundException('Ville non trouvée');
}
$now = new \DateTime();
$periods = [
'7j' => (clone $now)->modify('-7 days'),
'30j' => (clone $now)->modify('-30 days'),
'6mois' => (clone $now)->modify('-6 months'),
];
$followups = $stats->getCityFollowUps();
$types = [];
foreach ($followups as $fu) {
$name = $fu->getName();
if (str_ends_with($name, '_count')) {
$type = substr($name, 0, -6);
$types[$type][] = $fu;
}
}
$evolutions = [];
foreach ($types as $type => $fus) {
usort($fus, fn($a, $b) => $a->getDate() <=> $b->getDate());
$latest = end($fus);
$evolutions[$type] = [
'now' => $latest ? $latest->getMeasure() : null
];
foreach ($periods as $label => $date) {
$past = null;
foreach ($fus as $fu) {
if ($fu->getDate() >= $date) {
$past = $fu->getMeasure();
break;
}
}
$evolutions[$type][$label] = $past !== null && $latest ? $latest->getMeasure() - $past : null;
}
}
// Grouper les lieux par date de modification
$places = $stats->getPlaces();
$now = new \DateTime();
$places_7j = [];
$places_30j = [];
$places_6mois = [];
foreach ($places as $place) {
$mod = $place->getModifiedDate();
if (!$mod) continue;
$diff = $now->diff($mod);
$days = (int)$diff->format('%a');
if ($days <= 7) {
$places_7j[] = $place;
} elseif ($days <= 30) {
$places_30j[] = $place;
} elseif ($days <= 180) {
$places_6mois[] = $place;
}
}
// Tri décroissant par date
usort($places_7j, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate());
usort($places_30j, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate());
usort($places_6mois, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate());
return $this->render('public/stats_evolutions.html.twig', [
'stats' => $stats,
'evolutions' => $evolutions,
'periods' => array_keys($periods),
'places_7j' => $places_7j,
'places_30j' => $places_30j,
'places_6mois' => $places_6mois,
]);
}
}

View file

@ -752,4 +752,17 @@ class Place
$this->emailContent = $emailContent;
return $this;
}
public function toArray(): array
{
return [
'name' => $this->getName(),
'mainTag' => $this->getMainTag(),
'housenumber' => $this->getHousenumber(),
'street' => $this->getStreet(),
'completionPercentage' => $this->getCompletionPercentage(),
'lat' => $this->getLat(),
'lon' => $this->getLon(),
];
}
}

View file

@ -133,6 +133,9 @@
<button id="openInJOSM" class="btn btn-secondary ms-2">
<i class="bi bi-map"></i> Ouvrir dans JOSM
</button>
<a href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}" class="btn btn-outline-info ms-2">
<i class="bi bi-activity"></i> Évolutions des objets
</a>
</div>
</div>
{% if stats.population %}

View file

@ -79,7 +79,15 @@
</td>
<td class="{{ commerce.hasAddress() ? 'filled' : '' }}">{{ commerce.address }} </td>
<td class="{{ commerce.hasAddress() ? 'filled' : '' }}">{{ commerce.housenumber }}</td>
<td class="{{ commerce.hasAddress() ? 'filled' : '' }}">{{ commerce.street }}</td>
<td class="{{ commerce.hasAddress() ? 'filled' : '' }}">
{% if commerce.street %}
<a href="{{ path('app_public_street', {'cityId': commerce.stats.zone, 'streetName': commerce.street|url_encode }) }}">{{ commerce.street }}</a>
{% else %}
<span class="text-muted">(inconnue)</span>
{% endif %}
{# {{ commerce.street }} #}
</td>
<td class="{{ commerce.hasWebsite() ? 'filled' : '' }}">{{ commerce.website }}</td>
<td class="{{ commerce.hasWheelchair() ? 'filled' : '' }}">{{ commerce.wheelchair }}</td>
<td class="{{ commerce.hasNote() ? 'filled' : '' }}">{{ commerce.note }}</td>

View file

@ -19,7 +19,7 @@
</th>
<th>
<i class="bi bi-geo-alt"></i>
Adresse ({{ stats.getAvecAdresse() }} / {{ stats.places|length }})</th>
Adresse ({{ stats.getAvecAdresse is defined ? stats.getAvecAdresse : 0 }} / {{ stats.places|length }})</th>
<th>
<i class="bi bi-house-fill"></i>
@ -31,17 +31,17 @@
</th>
<th>
<i class="bi bi-globe"></i>
Site web ({{ stats.getAvecSite() }} / {{ stats.places|length }})
Site web ({{ stats.getAvecSite is defined ? stats.getAvecSite : 0 }} / {{ stats.places|length }})
</th>
<th>
<i class="bi bi-wheelchair"></i>
<i class="bi bi-person-fill-slash"></i>
Accès
PMR
({{ stats.getAvecAccessibilite() }} / {{ stats.places|length }})</th>
({{ stats.getAvecAccessibilite is defined ? stats.getAvecAccessibilite : 0 }} / {{ stats.places|length }})</th>
<th>
<i class="bi bi-pencil-square"></i>
Note ? ({{ stats.getAvecNote() }} / {{ stats.places|length }})</th>
Note ? ({{ stats.getAvecNote is defined ? stats.getAvecNote : 0 }} / {{ stats.places|length }})</th>
<th>
<i class="bi bi-pencil-square"></i>
Texte de la note</th>

View file

@ -10,6 +10,12 @@
<td>{{ place.modifiedDate | date('Y-m-d H:i:s') }}</td>
<td>{{ place.lastContactAttemptDate | date('Y-m-d H:i:s') }}</td>
<td>{{ place.modifiedDate | date('Y-m-d H:i:s') }}</td>
<td>{% if place.street %}
<a href="{{ path('app_public_street', {'cityId': place.stats ? place.stats.zone : place.zipCode, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
{% else %}
<span class="text-muted">(inconnue)</span>
{% endif %}
</td>
<td>{{ place.zipCode }}</td>
<td>
<a href="https://www.openstreetmap.org/{{place.osmKind}}/{{ place.osmId }}" ><i class="bi bi-globe"></i></a>

View file

@ -0,0 +1,141 @@
{% extends 'base.html.twig' %}
{% block title %}Évolutions des objets - {{ stats.name }}{% endblock %}
{% block body %}
<div class="container my-5">
<h1>Évolutions des objets à {{ stats.name }} ({{ stats.zone }})</h1>
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-secondary mb-3"><i class="bi bi-arrow-left"></i> Retour aux stats</a>
<div class="card">
<div class="card-header">
<strong>Variation des décomptes d'objets par type</strong>
</div>
<div class="card-body">
<table class="table table-bordered table-striped table-hover table-responsive table-sort">
<thead>
<tr>
<th>Type</th>
<th>Décompte actuel</th>
{% for p in periods %}
<th>Évolution sur {{ p }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for type, evo in evolutions %}
<tr>
<td>{{ type }}</td>
<td>{{ evo.now }}</td>
{% for p in periods %}
<td>{% if evo[p] is not null %}{{ evo[p] > 0 ? '+' ~ evo[p] : evo[p] }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
{% endfor %}
</tr>
{% else %}
<tr><td colspan="5" class="text-muted">Aucune donnée d'évolution trouvée.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="row mt-5">
<div class="col-md-4">
<h3>Lieux modifiés cette semaine</h3>
{% if places_7j|length > 0 %}
<ul class="list-group mb-4">
{% for place in places_7j %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
{{ place.name ?: '(sans nom)' }}
</a>
</strong><br>
<small>
{% if place.street %}
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
{% else %}
<span class="text-muted">(inconnue)</span>
{% endif %}
{{ place.housenumber }}
</small>
</span>
<span>
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Aucun lieu modifié dans les 7 derniers jours.</p>
{% endif %}
</div>
<div class="col-md-4">
<h3>Ce mois-ci</h3>
{% if places_30j|length > 0 %}
<ul class="list-group mb-4">
{% for place in places_30j %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
{{ place.name ?: '(sans nom)' }}
</a>
</strong><br>
<small>
{% if place.street %}
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
{% else %}
<span class="text-muted">(inconnue)</span>
{% endif %}
{{ place.housenumber }}
</small>
</span>
<span>
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Aucun lieu modifié dans les 30 derniers jours.</p>
{% endif %}
</div>
<div class="col-md-4">
<h3>6 derniers mois</h3>
{% if places_6mois|length > 0 %}
<ul class="list-group mb-4">
{% for place in places_6mois %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
{{ place.name ?: '(sans nom)' }}
</a>
</strong><br>
<small>
{% if place.street %}
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
{% else %}
<span class="text-muted">(inconnue)</span>
{% endif %}
{{ place.housenumber }}
</small>
</span>
<span>
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Aucun lieu modifié dans les 6 derniers mois.</p>
{% endif %}
</div>
</div>
<script src="{{ asset('js/table-sortable.js') }}"></script>
<script>document.querySelectorAll('table.table-sort').forEach(t => window.TableSortable && TableSortable.initTable(t));</script>
</div>
{% endblock %}

View file

@ -0,0 +1,109 @@
{% extends 'base.html.twig' %}
{# {% block title %}Suivi de la rue {{ street }} à {{ city }}{% endblock %} #}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet'/>
<style>
#streetMap { height: 400px; width: 100%; border-radius: 8px; margin-bottom: 2rem; }
</style>
{% endblock %}
{% block body %}
<div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>Rue <span class="text-primary">{{ street|e('html') }}</span> à <span class="text-success">{{ city }}</span></h1>
<a href="{{ stats_url }}" class="btn btn-outline-info my-2"><i class="bi bi-bar-chart"></i> Voir les statistiques de la ville</a>
</div>
<a href="{{ path('app_public_index') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Retour à l'accueil</a>
</div>
<div class="row mb-4">
<div class="col-md-6 col-12">
<h3>Répartition de la complétion</h3>
<canvas id="completionChart" height="200"></canvas>
</div>
<div class="col-md-6 col-12">
<h3>Carte des lieux</h3>
<div id="streetMap" style="height: 300px; width: 100%; border-radius: 8px;"></div>
<span class="is-info"> Si il manque des lieux dans cette rue c'est parce que les objets n'ont pas d'attribut "addr:street" et "addr:housenumber"</span>
</div>
</div>
<h2 class="mt-4">Lieux de la rue</h2>
<div class="table-responsive">
<table class="table table-bordered table-striped table-hover table-responsive table-sort">
{% include 'admin/stats/table-head.html.twig' with {stats: {places: places, getAvecAdresse: 0, getAvecSite: 0, getAvecAccessibilite: 0, getAvecNote: 0}} %}
<tbody>
{% for commerce in places %}
{% include 'admin/stats/row.html.twig' with {commerce: commerce, stats: {population: 0, places: places}} %}
{% else %}
<tr><td colspan="18" class="text-muted">Aucun lieu trouvé pour cette rue.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ asset('js/maplibre/maplibre-gl.js') }}"></script>
<script>
// Graphe de complétion
const completionData = {
labels: {{ completion_buckets|keys|json_encode|raw }},
datasets: [{
label: 'Nombre de lieux',
data: {{ completion_buckets_values|json_encode|raw }},
backgroundColor: [
'#e57373', '#ffb74d', '#fff176', '#81c784', '#64b5f6'
]
}]
};
new Chart(document.getElementById('completionChart'), {
type: 'bar',
data: completionData,
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, precision: 0 } }
}
});
// Carte MapLibre
const mapToken = '{{ maptiler_token }}';
const places = {{ places_js|json_encode|raw }};
let center = [2.35, 48.85];
if (places.length > 0) {
const avgLat = places.reduce((sum, p) => sum + (p.lat || 0), 0) / places.length;
const avgLon = places.reduce((sum, p) => sum + (p.lon || 0), 0) / places.length;
center = [avgLon, avgLat];
}
const map = new maplibregl.Map({
container: 'streetMap',
style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`,
center: center,
zoom: 16
});
// Couleurs selon la complétion
function getColor(percentage) {
if (percentage < 20) return '#e57373';
if (percentage < 40) return '#ffb74d';
if (percentage < 60) return '#fff176';
if (percentage < 80) return '#81c784';
return '#64b5f6';
}
places.forEach(place => {
if (place.lat && place.lon) {
new maplibregl.Marker({color: getColor(place.completionPercentage)})
.setLngLat([place.lon, place.lat])
.setPopup(new maplibregl.Popup().setHTML(`<strong>${place.name || '(sans nom)'}</strong><br>Complétion : ${place.completionPercentage}%`))
.addTo(map);
}
});
</script>
{% endblock %}