osm-labo/templates/public/dashboard.html.twig
2025-06-24 12:30:39 +02:00

426 lines
No EOL
18 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html.twig' %}
{% block title %}Tableau de bord{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet' />
<style>
.hidden {
display: none;
}
#mapDashboard {
height: 400px;
width: 100%;
margin-bottom: 1rem;
}
.suggestion-list {
position: absolute;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
width: 100%;
z-index: 1000;
display: none;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.suggestion-item:hover {
background-color: #f5f5f5;
}
.suggestion-name {
font-weight: bold;
}
.suggestion-details {
font-size: 0.9em;
color: #666;
}
.suggestion-type {
margin-right: 8px;
}
.search-container {
position: relative;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.bubble-chart-container {
min-height: 300px;
height: 60vw;
max-height: 400px;
overflow-x: auto;
}
}
@media (min-width: 769px) {
.bubble-chart-container {
min-height: 400px;
height: 50vw;
max-height: 600px;
overflow-x: auto;
}
}
</style>
{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="mb-4">Tableau de bord</h1>
</div>
</div>
<div class="row mb-2">
<div class="col-12 text-end">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" id="toggleBubbleSize" checked>
<label class="form-check-label" for="toggleBubbleSize">
Taille des bulles proportionnelle au nombre de lieux
</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
Statistiques des villes (bubble chart)
</div>
<div class="card-body bubble-chart-container">
<canvas id="bubbleChart" style="width: 100%; height: 100%; min-height: 300px;"></canvas>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
Fraîcheur des données OSM (âge moyen par trimestre)
</div>
<div class="card-body">
<canvas id="freshnessHistogram" style="min-height: 300px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h3 class="card-title">Labourer une ville</h3>
<form id="labourerForm">
<div class="search-container">
<input type="text"
id="citySearch"
class="form-control"
placeholder="Rechercher une ville..."
autocomplete="off">
<div id="citySuggestions" class="suggestion-list"></div>
</div>
<input type="hidden" name="zip_code" id="selectedZipCode">
<button type="submit" class="btn btn-primary mt-3">Labourer cette ville</button>
</form>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h2>Statistiques par ville</h2>
<div class="table-responsive">
<table class="table table-striped js-sort-table">
<thead>
<tr>
<th>Ville</th>
<th>Code postal</th>
<th>Complétion</th>
<th>Nombre de commerces</th>
<th>Lieux par habitants</th>
<th>Budget</th>
<th>Budget/habitant</th>
<th>Budget/lieu</th>
<th>Date moyenne de mise à jour</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for stat in stats_list %}
<tr>
<td><a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" title="Voir les statistiques de cette ville">
{{ stat.name }}
{% if not stat.name and stat.zone starts with '751' %}
Paris {{ stat.zone|slice(-2) }}e.
{% endif %}
</a></td>
<td>{{ stat.zone }}</td>
<td>{{ stat.completionPercent }}%</td>
<td>{{ stat.placesCount }}</td>
<td>{{ (stat.placesCount / (stat.population or 1 ))|round(2) }}</td>
<td>{% if stat.budgetAnnuel %}{{ stat.budgetAnnuel|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.population %}{{ (stat.budgetAnnuel / stat.population)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.placesCount %}{{ (stat.budgetAnnuel / stat.placesCount)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{{ stat.osmDataDateAvg|date('Y-m-d H:i') }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" class="btn btn-sm btn-primary" title="Voir les statistiques de cette ville">
<i class="bi bi-eye"></i>
</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stat.zone, 'deleteMissing': 1}) }}"
class="btn btn-sm btn-success btn-labourer"
data-zip-code="{{ stat.zone }}"
title="Labourer cette ville"
>
<i class="bi bi-recycle"></i>
</a>
<a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')"
title="Supprimer cette ville"
>
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<script>
window.statsDataForBubble = {{ stats|raw }};
document.addEventListener('DOMContentLoaded', function() {
const chartCanvas = document.getElementById('bubbleChart');
if (!chartCanvas) {
console.warn('Canvas #bubbleChart introuvable');
return;
}
const statsData = {{ stats|raw }};
console.log('statsData', statsData);
if (!statsData || statsData.length === 0) {
chartCanvas.parentNode.innerHTML += '<div class="text-muted p-3">Aucune donnée de statistiques à afficher pour le graphique.</div>';
return;
}
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
};
});
// Calcul de la régression linéaire (moindres carrés)
const validPoints = bubbleChartData.filter(d => d.x > 0 && d.y > 0);
const n = validPoints.length;
let regressionLine = null, slope = 0, intercept = 0;
if (n >= 2) {
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
validPoints.forEach(d => {
sumX += Math.log10(d.x);
sumY += d.y;
sumXY += Math.log10(d.x) * d.y;
sumXX += Math.log10(d.x) * Math.log10(d.x);
});
const meanX = sumX / n;
const meanY = sumY / n;
slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX);
intercept = meanY - slope * meanX;
const xMin = Math.min(...validPoints.map(d => d.x));
const xMax = Math.max(...validPoints.map(d => d.x));
regressionLine = [
{ x: xMin, y: slope * Math.log10(xMin) + intercept },
{ x: xMax, y: slope * Math.log10(xMax) + intercept }
];
}
Chart.register(window.ChartDataLabels);
const bubbleChart = new Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [
{
label: 'Villes',
data: bubbleChartData,
backgroundColor: bubbleChartData.map(d => `rgba(75, 192, 192, ${d.completion / 100})`),
borderColor: 'rgba(75, 192, 192, 1)',
},
].filter(Boolean)
},
options: {
responsive: true,
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#000',
font: {
weight: '400',
size: "10px",
},
formatter: (value, context) => {
return context.dataset.data[context.dataIndex].label;
}
},
legend: {
display: true
},
tooltip: {
callbacks: {
label: (context) => {
const d = context.raw;
if (context.dataset.type === 'line') {
return `Régression: y = ${slope.toFixed(2)} × log10(x) + ${intercept.toFixed(2)}`;
}
return [
`${d.label}`,
`Population: ${d.x.toLocaleString()}`,
`Lieux / 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'
}
}
}
}
});
// Ajout du clic sur une bulle
chartCanvas.onclick = function(evt) {
const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (points.length > 0) {
const firstPoint = points[0];
const dataIndex = firstPoint.index;
const stat = statsData[dataIndex];
if (stat && stat.zone) {
window.location.href = '/admin/stats/' + stat.zone;
}
}
};
// HISTOGRAMME FRAÎCHEUR
const freshnessCanvas = document.getElementById('freshnessHistogram');
if (freshnessCanvas) {
// Regrouper les villes par trimestre d'âge moyen OSM
const statsData = {{ stats|raw }};
const quarterCounts = {};
const quarterCities = {};
statsData.forEach(stat => {
if (!stat.osmDataDateAvg) return;
const date = new Date(stat.osmDataDateAvg);
if (isNaN(date)) return;
const year = date.getFullYear();
const month = date.getMonth(); // 0-11
const quarter = Math.floor(month / 3) + 1;
const key = `${year}-T${quarter}`;
if (!quarterCounts[key]) quarterCounts[key] = 0;
if (!quarterCities[key]) quarterCities[key] = [];
quarterCounts[key]++;
quarterCities[key].push(stat.name);
});
// Ordonner les trimestres
const sortedKeys = Object.keys(quarterCounts).sort();
const data = sortedKeys.map(k => quarterCounts[k]);
const labels = sortedKeys;
const citiesPerQuarter = sortedKeys.map(k => quarterCities[k]);
new Chart(freshnessCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Nombre de villes',
data: data,
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: { weight: 'bold', size: 10 },
formatter: function(value, context) {
const idx = context.dataIndex;
const villes = citiesPerQuarter[idx] || [];
if (villes.length === 0) return '';
let txt = villes.slice(0, 5).join(', ');
if (villes.length > 5) txt += ', ...';
return txt;
}
}
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) {
const idx = ctx.dataIndex;
const villes = citiesPerQuarter[idx] || [];
let label = `Villes : ${ctx.parsed.y}`;
if (villes.length > 0) {
label += '\n' + villes.join(', ');
}
return label;
}
}
},
datalabels: {
display: true
}
},
scales: {
x: {
title: { display: true, text: 'Trimestre de l\'âge moyen OSM' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Nombre de villes' }
}
}
},
plugins: [ChartDataLabels]
});
}
});
</script>
{% endblock %}