map on home
This commit is contained in:
parent
a5cd69961f
commit
56f62c45bb
14 changed files with 588 additions and 15 deletions
|
@ -12,6 +12,11 @@ if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimisations pour éviter les timeouts
|
||||||
|
ini_set('max_execution_time', 300);
|
||||||
|
ini_set('memory_limit', '512M');
|
||||||
|
set_time_limit(300);
|
||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
return function (array $context) {
|
return function (array $context) {
|
||||||
|
|
|
@ -4,6 +4,11 @@ doctrine:
|
||||||
options:
|
options:
|
||||||
1002: "SET NAMES utf8mb4"
|
1002: "SET NAMES utf8mb4"
|
||||||
1000: true
|
1000: true
|
||||||
|
# Optimisations pour éviter les timeouts
|
||||||
|
PDO::ATTR_TIMEOUT: 300
|
||||||
|
PDO::ATTR_PERSISTENT: false
|
||||||
|
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY: true
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND: "SET SESSION wait_timeout=300, interactive_timeout=300"
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
|
|
|
@ -5,7 +5,16 @@ framework:
|
||||||
http_method_override: false
|
http_method_override: false
|
||||||
handle_all_throwables: true
|
handle_all_throwables: true
|
||||||
|
|
||||||
|
# Optimisations pour éviter les timeouts
|
||||||
|
cache:
|
||||||
|
app: cache.adapter.filesystem
|
||||||
|
system: cache.adapter.system
|
||||||
|
directory: '%kernel.cache_dir%/pools/app'
|
||||||
|
default_redis_provider: 'redis://localhost'
|
||||||
|
default_memcached_provider: 'memcached://localhost'
|
||||||
|
default_doctrine_dbal_provider: database_connection
|
||||||
|
default_pdo_provider: null
|
||||||
|
pools: { }
|
||||||
|
|
||||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
||||||
# Remove or comment this section to explicitly disable session support.
|
# Remove or comment this section to explicitly disable session support.
|
||||||
|
|
|
@ -4,6 +4,11 @@ use App\Kernel;
|
||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
// Optimisations pour éviter les timeouts
|
||||||
|
ini_set('max_execution_time', 300);
|
||||||
|
ini_set('memory_limit', '1024M');
|
||||||
|
set_time_limit(300);
|
||||||
|
|
||||||
return function (array $context) {
|
return function (array $context) {
|
||||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
};
|
};
|
||||||
|
|
|
@ -471,6 +471,61 @@ final class AdminController extends AbstractController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/stats/{insee_code}/followup-graph/{theme}', name: 'admin_followup_theme_graph', requirements: ['insee_code' => '\\d+'])]
|
||||||
|
public function followupThemeGraph(string $insee_code, string $theme): Response
|
||||||
|
{
|
||||||
|
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
|
||||||
|
if (!$stats) {
|
||||||
|
$this->addFlash('error', 'Aucune stats trouvée pour ce code INSEE.');
|
||||||
|
return $this->redirectToRoute('app_admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$themes = \App\Service\FollowUpService::getFollowUpThemes();
|
||||||
|
if (!isset($themes[$theme])) {
|
||||||
|
$this->addFlash('error', 'Thème non reconnu.');
|
||||||
|
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer toutes les données de followup pour ce thème
|
||||||
|
$followups = $stats->getCityFollowUps();
|
||||||
|
$countData = [];
|
||||||
|
$completionData = [];
|
||||||
|
|
||||||
|
foreach ($followups as $fu) {
|
||||||
|
if ($fu->getName() === $theme . '_count') {
|
||||||
|
$countData[] = [
|
||||||
|
'date' => $fu->getDate()->format('Y-m-d'),
|
||||||
|
'value' => $fu->getMeasure()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($fu->getName() === $theme . '_completion') {
|
||||||
|
$completionData[] = [
|
||||||
|
'date' => $fu->getDate()->format('Y-m-d'),
|
||||||
|
'value' => $fu->getMeasure()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trier par date
|
||||||
|
usort($countData, fn($a, $b) => $a['date'] <=> $b['date']);
|
||||||
|
usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']);
|
||||||
|
|
||||||
|
$this->actionLogger->log('followup_theme_graph', [
|
||||||
|
'insee_code' => $insee_code,
|
||||||
|
'theme' => $theme,
|
||||||
|
'theme_label' => $themes[$theme] ?? 'Unknown'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->render('admin/followup_theme_graph.html.twig', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'theme' => $theme,
|
||||||
|
'theme_label' => $themes[$theme],
|
||||||
|
'count_data' => json_encode($countData),
|
||||||
|
'completion_data' => json_encode($completionData),
|
||||||
|
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')]
|
#[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')]
|
||||||
public function placeType(string $osm_kind, string $osm_id): Response
|
public function placeType(string $osm_kind, string $osm_id): Response
|
||||||
{
|
{
|
||||||
|
@ -484,11 +539,7 @@ final class AdminController extends AbstractController
|
||||||
]);
|
]);
|
||||||
return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]);
|
return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]);
|
||||||
} else {
|
} else {
|
||||||
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id,
|
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
|
||||||
'name' => $place->getName(),
|
|
||||||
'code_insee' => $place->getZipCode(),
|
|
||||||
'uuid' => $place->getUuidForUrl()
|
|
||||||
]);
|
|
||||||
$this->addFlash('error', 'Le lieu n\'existe pas.');
|
$this->addFlash('error', 'Le lieu n\'existe pas.');
|
||||||
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
|
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
|
||||||
return $this->redirectToRoute('app_public_index');
|
return $this->redirectToRoute('app_public_index');
|
||||||
|
|
|
@ -120,12 +120,67 @@ class PublicController extends AbstractController
|
||||||
{
|
{
|
||||||
$stats = $this->entityManager->getRepository(Stats::class)->findAll();
|
$stats = $this->entityManager->getRepository(Stats::class)->findAll();
|
||||||
|
|
||||||
|
// Préparer les données pour la carte
|
||||||
|
$citiesForMap = [];
|
||||||
|
foreach ($stats as $stat) {
|
||||||
|
if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) {
|
||||||
|
// Récupérer les coordonnées de la ville via l'API Nominatim
|
||||||
|
$cityName = $stat->getName() ?: $stat->getZone();
|
||||||
|
$coordinates = $this->getCityCoordinates($cityName, $stat->getZone());
|
||||||
|
|
||||||
|
if ($coordinates) {
|
||||||
|
$citiesForMap[] = [
|
||||||
|
'name' => $cityName,
|
||||||
|
'zone' => $stat->getZone(),
|
||||||
|
'coordinates' => $coordinates,
|
||||||
|
'placesCount' => $stat->getPlacesCount(),
|
||||||
|
'completionPercent' => $stat->getCompletionPercent(),
|
||||||
|
'population' => $stat->getPopulation(),
|
||||||
|
'url' => $this->generateUrl('app_admin_stats', ['insee_code' => $stat->getZone()])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('public/home.html.twig', [
|
return $this->render('public/home.html.twig', [
|
||||||
'controller_name' => 'PublicController',
|
'controller_name' => 'PublicController',
|
||||||
'stats' => $stats
|
'stats' => $stats,
|
||||||
|
'citiesForMap' => $citiesForMap,
|
||||||
|
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les coordonnées d'une ville via l'API Nominatim
|
||||||
|
*/
|
||||||
|
private function getCityCoordinates(string $cityName, string $inseeCode): ?array
|
||||||
|
{
|
||||||
|
// Cache simple pour éviter trop d'appels API
|
||||||
|
$cacheKey = 'city_coords_' . $inseeCode;
|
||||||
|
|
||||||
|
// Vérifier le cache (ici on utilise une approche simple)
|
||||||
|
// En production, vous pourriez utiliser le cache Symfony
|
||||||
|
|
||||||
|
$query = urlencode($cityName . ', France');
|
||||||
|
$url = "https://nominatim.openstreetmap.org/search?q={$query}&format=json&limit=1&countrycodes=fr";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = file_get_contents($url);
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
|
||||||
|
if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) {
|
||||||
|
return [
|
||||||
|
'lat' => (float) $data[0]['lat'],
|
||||||
|
'lon' => (float) $data[0]['lon']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// En cas d'erreur, on retourne null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/edit/{zipcode}/{name}/{uuid}', name: 'app_public_edit')]
|
#[Route('/edit/{zipcode}/{name}/{uuid}', name: 'app_public_edit')]
|
||||||
public function edit_with_uuid($zipcode, $name, $uuid): Response
|
public function edit_with_uuid($zipcode, $name, $uuid): Response
|
||||||
{
|
{
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<h1><i class="bi {{ icon }} fs-2"></i> {{ label }} <small class="text-muted">- {{ stats.name }}</small></h1>
|
<h1><i class="bi {{ icon }} fs-2"></i> {{ label }} <small class="text-muted">- {{ stats.name }}</small></h1>
|
||||||
<canvas id="embedThemeChart" width="600" height="300"></canvas>
|
<canvas id="embedThemeChart" width="600" height="400"></canvas>
|
||||||
<div class="mb-3 mt-2">
|
<div class="mb-3 mt-2">
|
||||||
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-info me-2">
|
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-info me-2">
|
||||||
<i class="bi bi-bar-chart"></i> Voir la page de la ville
|
<i class="bi bi-bar-chart"></i> Voir la page de la ville
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h3>Nombre de villes et complétion moyenne</h3>
|
<h3>Nombre de villes et complétion moyenne</h3>
|
||||||
<canvas id="global-summary-chart" height="80"></canvas>
|
<canvas id="global-summary-chart" height="400"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<h3>Suivi par thématique</h3>
|
<h3>Suivi par thématique</h3>
|
||||||
|
@ -23,8 +23,8 @@
|
||||||
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }}"></i>
|
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }}"></i>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</h5>
|
</h5>
|
||||||
<canvas id="chart-{{ type }}-count" height="60"></canvas>
|
<canvas id="chart-{{ type }}-count" height="400"></canvas>
|
||||||
<canvas id="chart-{{ type }}-completion" height="60"></canvas>
|
<canvas id="chart-{{ type }}-completion" height="400"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
<p>Historique des objets suivis (nombre et complétion).</p>
|
<p>Historique des objets suivis (nombre et complétion).</p>
|
||||||
{% for type, label in followup_labels %}
|
{% for type, label in followup_labels %}
|
||||||
<h2 id="title-{{ type }}"><i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2"></i> {{ label }}</h2>
|
<h2 id="title-{{ type }}"><i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2"></i> {{ label }}</h2>
|
||||||
<canvas id="{{ type }}Chart" width="600" height="300"></canvas>
|
<canvas id="{{ type }}Chart" width="600" height="400"></canvas>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{% set overpass_query = '[out:json][timeout:60];\narea["ref:INSEE"="' ~ stats.zone ~ '"]->.searchArea;\n(' ~ followup_overpass[type]|default('') ~ ');\n\n(._;>;);\n\nout meta;\n>;' %}
|
{% set overpass_query = '[out:json][timeout:60];\narea["ref:INSEE"="' ~ stats.zone ~ '"]->.searchArea;\n(' ~ followup_overpass[type]|default('') ~ ');\n\n(._;>;);\n\nout meta;\n>;' %}
|
||||||
<a href="https://overpass-turbo.eu/?Q={{ overpass_query|url_encode }}" target="_blank" class="btn btn-sm btn-outline-primary me-2">
|
<a href="https://overpass-turbo.eu/?Q={{ overpass_query|url_encode }}" target="_blank" class="btn btn-sm btn-outline-primary me-2">
|
||||||
|
|
275
templates/admin/followup_theme_graph.html.twig
Normal file
275
templates/admin/followup_theme_graph.html.twig
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
{% extends 'base_embed.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Graphique {{ theme_label }} - {{ stats.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tab.active {
|
||||||
|
border-bottom-color: #0d6efd;
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="stats-header">
|
||||||
|
<h2>
|
||||||
|
<i class="bi {{ icons[theme]|default('bi-question-circle') }}"></i>
|
||||||
|
{{ theme_label }} - {{ stats.name }}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-0">Code INSEE: {{ stats.zone }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="currentCount">-</div>
|
||||||
|
<div class="stat-label">Nombre actuel</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="currentCompletion">-</div>
|
||||||
|
<div class="stat-label">Complétion actuelle</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="dataPoints">-</div>
|
||||||
|
<div class="stat-label">Points de données</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="lastUpdate">-</div>
|
||||||
|
<div class="stat-label">Dernière mise à jour</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-tabs">
|
||||||
|
<button class="chart-tab active" data-chart="count">Nombre d'objets</button>
|
||||||
|
<button class="chart-tab" data-chart="completion">Pourcentage de complétion</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-content active" id="count-chart">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="countChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-content" id="completion-chart">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="completionChart"></canvas>
|
||||||
|
</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-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const countData = {{ count_data|raw }};
|
||||||
|
const completionData = {{ completion_data|raw }};
|
||||||
|
|
||||||
|
// Mettre à jour les statistiques
|
||||||
|
function updateStats() {
|
||||||
|
if (countData.length > 0) {
|
||||||
|
const latestCount = countData[countData.length - 1];
|
||||||
|
document.getElementById('currentCount').textContent = latestCount.value;
|
||||||
|
document.getElementById('lastUpdate').textContent = new Date(latestCount.date).toLocaleDateString('fr-FR');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionData.length > 0) {
|
||||||
|
const latestCompletion = completionData[completionData.length - 1];
|
||||||
|
document.getElementById('currentCompletion').textContent = latestCompletion.value + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dataPoints').textContent = Math.max(countData.length, completionData.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration commune pour les graphiques
|
||||||
|
const commonOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day',
|
||||||
|
displayFormats: {
|
||||||
|
day: 'dd/MM/yyyy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Date'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graphique du nombre d'objets
|
||||||
|
const countCtx = document.getElementById('countChart').getContext('2d');
|
||||||
|
const countChart = new Chart(countCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
label: 'Nombre d\'objets',
|
||||||
|
data: countData.map(d => ({
|
||||||
|
x: new Date(d.date),
|
||||||
|
y: d.value
|
||||||
|
})),
|
||||||
|
borderColor: '#0d6efd',
|
||||||
|
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
...commonOptions.scales,
|
||||||
|
y: {
|
||||||
|
...commonOptions.scales.y,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Nombre d\'objets'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graphique de la complétion
|
||||||
|
const completionCtx = document.getElementById('completionChart').getContext('2d');
|
||||||
|
const completionChart = new Chart(completionCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
label: 'Pourcentage de complétion',
|
||||||
|
data: completionData.map(d => ({
|
||||||
|
x: new Date(d.date),
|
||||||
|
y: d.value
|
||||||
|
})),
|
||||||
|
borderColor: '#198754',
|
||||||
|
backgroundColor: 'rgba(25, 135, 84, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
...commonOptions.scales,
|
||||||
|
y: {
|
||||||
|
...commonOptions.scales.y,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Pourcentage de complétion (%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestion des onglets
|
||||||
|
document.querySelectorAll('.chart-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
// Retirer la classe active de tous les onglets
|
||||||
|
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.chart-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
// Ajouter la classe active à l'onglet cliqué
|
||||||
|
this.classList.add('active');
|
||||||
|
const chartId = this.getAttribute('data-chart') + '-chart';
|
||||||
|
document.getElementById(chartId).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialiser les statistiques
|
||||||
|
updateStats();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -170,6 +170,9 @@
|
||||||
<span class="completion-badge {{ completion_class }}"></span><br>
|
<span class="completion-badge {{ completion_class }}"></span><br>
|
||||||
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2 mb-1"></i><br>
|
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2 mb-1"></i><br>
|
||||||
<a href="http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data={{ overpass_query|url_encode }}" target="_blank" class="fw-bold text-decoration-underline text-dark" title="Charger dans JOSM">{{ followup_labels[type]|default(type|capitalize) }}</a><br>
|
<a href="http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data={{ overpass_query|url_encode }}" target="_blank" class="fw-bold text-decoration-underline text-dark" title="Charger dans JOSM">{{ followup_labels[type]|default(type|capitalize) }}</a><br>
|
||||||
|
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" target="_blank" class="btn btn-sm btn-outline-primary mt-1" title="Voir le graphique">
|
||||||
|
<i class="bi bi-graph-up"></i>
|
||||||
|
</a><br>
|
||||||
<span title="Nombre"> {{ data.count is defined ? data.count.getMeasure() : '?' }}</span><br>
|
<span title="Nombre"> {{ data.count is defined ? data.count.getMeasure() : '?' }}</span><br>
|
||||||
<span title="Complétion"> {{ completion is not null ? completion : '?' }}%</span>
|
<span title="Complétion"> {{ completion is not null ? completion : '?' }}%</span>
|
||||||
{% if progression7Days[type] is defined %}
|
{% if progression7Days[type] is defined %}
|
||||||
|
@ -200,6 +203,9 @@
|
||||||
<span class="completion-badge" style="background:#eee;"></span><br>
|
<span class="completion-badge" style="background:#eee;"></span><br>
|
||||||
<i class="bi bi-question-circle fs-2 mb-1"></i><br>
|
<i class="bi bi-question-circle fs-2 mb-1"></i><br>
|
||||||
<span class="fw-bold">{{ followup_labels[type]|default(type|capitalize) }}</span><br>
|
<span class="fw-bold">{{ followup_labels[type]|default(type|capitalize) }}</span><br>
|
||||||
|
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" target="_blank" class="btn btn-sm btn-outline-secondary mt-1" title="Voir le graphique">
|
||||||
|
<i class="bi bi-graph-up"></i> Graphique
|
||||||
|
</a><br>
|
||||||
<span title="Nombre">N = ?</span><br>
|
<span title="Nombre">N = ?</span><br>
|
||||||
<span title="Complétion">?%</span>
|
<span title="Complétion">?%</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -280,7 +286,7 @@
|
||||||
<div class="row ">
|
<div class="row ">
|
||||||
|
|
||||||
<div class="col-md-6 col-12 ">
|
<div class="col-md-6 col-12 ">
|
||||||
<canvas id="repartition_tags" width="600" height="300" style="max-width:100%; margin: 20px 0;"></canvas>
|
<canvas id="repartition_tags" width="600" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -303,7 +309,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
{% include 'admin/stats_history.html.twig' with {stat: stats} %}
|
{% include 'admin/stats_history.html.twig' with {stat: stats} %}
|
||||||
<canvas id="distribution_completion" class="mt-4 mb-4" height="600"></canvas>
|
<canvas id="distribution_completion" class="mt-4 mb-4" height="400"></canvas>
|
||||||
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<h2>Évolution du taux de complétion</h2>
|
<h2>Évolution du taux de complétion</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<canvas id="completionHistoryChart" height="500"></canvas>
|
<canvas id="completionHistoryChart" height="400" style="height:400px !important; max-height:400px; min-height:200px;"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,46 @@
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
|
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet'/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
#citiesMap {
|
||||||
|
height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.city-list {
|
.city-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
@ -93,6 +131,38 @@
|
||||||
vous aider.
|
vous aider.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if citiesForMap is not empty %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="bi bi-geo-alt"></i> Carte des villes disponibles</h3>
|
||||||
|
<p class="mb-0">Cliquez sur un marqueur pour voir les statistiques de la ville</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="map-container">
|
||||||
|
<div id="citiesMap"></div>
|
||||||
|
<div class="map-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #28a745;"></div>
|
||||||
|
<span>Complétion > 80%</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #17a2b8;"></div>
|
||||||
|
<span>Complétion 50-80%</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #ffc107;"></div>
|
||||||
|
<span>Complétion < 50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="label" for="researchShop">
|
<label class="label" for="researchShop">
|
||||||
|
@ -148,6 +218,7 @@
|
||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
|
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
|
||||||
{# <script src='{{ asset('js/utils.js') }}'></script> #}
|
{# <script src='{{ asset('js/utils.js') }}'></script> #}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
// import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
|
// import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
|
||||||
|
@ -157,6 +228,96 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Données des villes pour la carte
|
||||||
|
const citiesData = {{ citiesForMap|json_encode|raw }};
|
||||||
|
const mapToken = '{{ maptiler_token }}';
|
||||||
|
|
||||||
|
// Initialiser la carte si des données sont disponibles
|
||||||
|
if (citiesData.length > 0 && mapToken) {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Créer les features GeoJSON pour la carte
|
||||||
|
const features = citiesData.map(city => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [city.coordinates.lon, city.coordinates.lat]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
name: city.name,
|
||||||
|
zone: city.zone,
|
||||||
|
placesCount: city.placesCount,
|
||||||
|
completionPercent: city.completionPercent,
|
||||||
|
population: city.population,
|
||||||
|
url: city.url
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const geojson = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: features
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculer le centre de la carte (moyenne des coordonnées)
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
features.forEach(feature => {
|
||||||
|
bounds.extend(feature.geometry.coordinates);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialiser la carte
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: 'citiesMap',
|
||||||
|
style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`,
|
||||||
|
bounds: bounds,
|
||||||
|
fitBoundsOptions: {
|
||||||
|
padding: 50
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter les marqueurs
|
||||||
|
features.forEach(feature => {
|
||||||
|
const properties = feature.properties;
|
||||||
|
|
||||||
|
// Déterminer la couleur selon le pourcentage de complétion
|
||||||
|
let color = '#ffc107'; // Jaune par défaut
|
||||||
|
if (properties.completionPercent > 80) {
|
||||||
|
color = '#28a745'; // Vert
|
||||||
|
} else if (properties.completionPercent > 50) {
|
||||||
|
color = '#17a2b8'; // Bleu
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le marqueur
|
||||||
|
const marker = new maplibregl.Marker({
|
||||||
|
color: color,
|
||||||
|
scale: 0.8
|
||||||
|
})
|
||||||
|
.setLngLat(feature.geometry.coordinates)
|
||||||
|
.setPopup(
|
||||||
|
new maplibregl.Popup({ offset: 25 })
|
||||||
|
.setHTML(`
|
||||||
|
<div style="min-width: 200px;">
|
||||||
|
<h6 style="margin: 0 0 10px 0; color: #333;">${properties.name}</h6>
|
||||||
|
<div style="font-size: 12px; color: #666;">
|
||||||
|
<div><strong>Code INSEE:</strong> ${properties.zone}</div>
|
||||||
|
<div><strong>Lieux:</strong> ${properties.placesCount}</div>
|
||||||
|
<div><strong>Complétion:</strong> ${properties.completionPercent}%</div>
|
||||||
|
${properties.population ? `<div><strong>Population:</strong> ${properties.population.toLocaleString()}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<a href="${properties.url}" class="btn btn-sm btn-primary" style="text-decoration: none;">
|
||||||
|
Voir les statistiques
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter les contrôles de navigation
|
||||||
|
map.addControl(new maplibregl.NavigationControl());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Créer le formulaire email
|
// Créer le formulaire email
|
||||||
const emailFormHtml = `
|
const emailFormHtml = `
|
||||||
<form id="emailForm" class="mt-3">
|
<form id="emailForm" class="mt-3">
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
<li><a class="dropdown-item" href="{{ path('app_public_latest_changes') }}"><i class="bi bi-clock-fill"></i> {{ 'display.latest_changes'|trans }}</a></li>
|
<li><a class="dropdown-item" href="{{ path('app_public_latest_changes') }}"><i class="bi bi-clock-fill"></i> {{ 'display.latest_changes'|trans }}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ path('admin_fraicheur_histogramme') }}"><i class="bi bi-clock-history"></i> Fraîcheur de la donnée</a></li>
|
<li><a class="dropdown-item" href="{{ path('admin_fraicheur_histogramme') }}"><i class="bi bi-clock-history"></i> Fraîcheur de la donnée</a></li>
|
||||||
<li><a class="dropdown-item" href="/api/v1/stats/export?pretty=1"><i class="bi bi-download"></i> Export JSON des villes</a></li>
|
<li><a class="dropdown-item" href="/api/v1/stats/export?pretty=1"><i class="bi bi-download"></i> Export JSON des villes</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/export_csv"><i class="bi bi-filetype-csv"></i> Exporter les villes (CSV)</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue