add dashboard

This commit is contained in:
Tykayn 2025-08-30 18:20:50 +02:00 committed by tykayn
parent 3a7a3849ae
commit ef801609cb
6 changed files with 822 additions and 1 deletions

View file

@ -93,6 +93,24 @@ Ceci alimente un fichier csv de suivi des évolutions et présente les changemen
Le CSV contient les décomptes de mots pour livre.org, personnages.org, le nombre de personnages, de chapitres, et de sous chapitres.
## Tableau de bord web
Un tableau de bord web interactif est disponible pour visualiser les données de votre livre. Ce tableau de bord inclut:
- Statistiques générales (mots, chapitres, personnages, intrigues)
- Graphique de progression de l'écriture
- Graphique de réseau des personnages (déplaçable à la souris)
- Chronologie des intrigues
Pour générer le tableau de bord, exécutez:
```bash
./generate_dashboard.py
```
Le tableau de bord sera généré dans le dossier `build/` et pourra être visualisé en ouvrant `build/dashboard.html` dans votre navigateur.
Pour plus de détails sur l'utilisation du tableau de bord, consultez le fichier [dashboard_instructions.md](dashboard_instructions.md).
# Licence
AGPLv3+

65
dashboard_instructions.md Normal file
View file

@ -0,0 +1,65 @@
# Tableau de Bord du Livre
Ce document explique comment utiliser le tableau de bord pour visualiser les données de votre livre.
## Génération du Tableau de Bord
Pour générer le tableau de bord, exécutez le script `generate_dashboard.py` :
```bash
./generate_dashboard.py
```
Le script va créer un fichier HTML et les ressources associées dans le dossier `build/`.
## Visualisation du Tableau de Bord
Ouvrez le fichier `build/dashboard.html` dans votre navigateur web pour visualiser le tableau de bord.
## Fonctionnalités du Tableau de Bord
Le tableau de bord comprend plusieurs visualisations interactives :
### 1. Statistiques Générales
Affiche un résumé des statistiques clés de votre livre :
- Nombre total de mots
- Nombre de chapitres
- Nombre de personnages
- Nombre d'intrigues
### 2. Progression de l'Écriture
Un graphique interactif montrant l'évolution du nombre de mots et de chapitres au fil du temps.
### 3. Réseau des Personnages
Un graphique de réseau interactif montrant les relations entre les personnages de votre livre.
- Les personnages sont représentés par des nœuds
- Les liens entre les nœuds indiquent que les personnages apparaissent ensemble dans un ou plusieurs chapitres
- L'épaisseur des liens représente la fréquence des interactions
- **Interaction** : Vous pouvez cliquer et faire glisser les nœuds pour réorganiser le graphique
### 4. Chronologie des Intrigues
Un diagramme de Gantt montrant la chronologie des différentes intrigues de votre livre.
- L'axe horizontal représente la progression temporelle
- Chaque barre représente une intrigue
- **Interaction** : Passez la souris sur une intrigue pour voir plus de détails
## Mise à Jour des Données
Le tableau de bord utilise les fichiers de données suivants :
- `suivi_livre.csv` : Données de progression de l'écriture
- `occurrences_personnages.csv` : Données sur les apparitions des personnages
- `intrigues.csv` : Données sur les intrigues
Pour mettre à jour le tableau de bord avec les dernières données, il suffit de réexécuter le script `generate_dashboard.py`.
## Personnalisation
Si vous souhaitez personnaliser l'apparence ou le comportement du tableau de bord, vous pouvez modifier les fichiers suivants :
- `build/static/css/dashboard.css` : Style du tableau de bord
- `build/static/js/network-graph.js` : Comportement du graphique de réseau
- `build/static/js/progress-chart.js` : Comportement du graphique de progression
- `build/static/js/plot-timeline.js` : Comportement du diagramme des intrigues

48
fix_csv_execution.py Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
Script pour ajouter un commentaire en début de fichier CSV afin d'éviter
qu'il soit exécuté directement comme un script Python.
"""
import os
import sys
def fix_csv_file(csv_file_path):
"""
Ajoute un commentaire en début de fichier CSV pour éviter l'exécution directe.
"""
if not os.path.exists(csv_file_path):
print(f"Erreur: Le fichier {csv_file_path} n'existe pas.")
return False
# Lire le contenu actuel du fichier
with open(csv_file_path, 'r') as f:
content = f.read()
# Vérifier si le fichier commence déjà par un commentaire
if content.startswith('#'):
print(f"Le fichier {csv_file_path} est déjà protégé.")
return True
# Ajouter le commentaire au début du fichier
with open(csv_file_path, 'w') as f:
f.write('# Ce fichier est un CSV et ne doit pas être exécuté directement avec Python.\n')
f.write(content)
print(f"Le fichier {csv_file_path} a été protégé contre l'exécution directe.")
return True
def main():
"""
Fonction principale.
"""
# Utiliser le fichier spécifié en argument ou par défaut 'suivi_livre.csv'
if len(sys.argv) > 1:
csv_file = sys.argv[1]
else:
csv_file = 'suivi_livre.csv'
fix_csv_file(csv_file)
if __name__ == "__main__":
main()

View file

@ -3,6 +3,7 @@
# colonnes du csv: date; mots; intrigues; personnages; personnages_mots;
import csv
import os
from datetime import date, timedelta, datetime
def mise_a_jour_suivi(fichier_csv, fichier_livre, fichier_personnages):
@ -23,6 +24,32 @@ def mise_a_jour_suivi(fichier_csv, fichier_livre, fichier_personnages):
intrigues = 5
# Mettre à jour le fichier de suivi
# Vérifier si le fichier existe et s'il commence par un commentaire
file_exists = os.path.exists(fichier_csv)
needs_comment = False
if not file_exists:
needs_comment = True
else:
with open(fichier_csv, 'r') as f:
first_line = f.readline().strip()
if not first_line.startswith('#'):
needs_comment = True
if needs_comment:
# Sauvegarder le contenu existant si le fichier existe
content = ""
if file_exists:
with open(fichier_csv, 'r') as f:
content = f.read()
# Réécrire le fichier avec un commentaire en tête
with open(fichier_csv, 'w', newline='') as f:
f.write('# Ce fichier est un CSV et ne doit pas être exécuté directement avec Python.\n')
if content:
f.write(content)
# Ajouter la nouvelle ligne
with open(fichier_csv, 'a', newline='') as csvfile:
writer = csv.writer(csvfile, delimiter=';')
now = datetime.now()
@ -38,7 +65,16 @@ def analyse_csv(fichier_csv):
with open(fichier_csv, 'r') as csvfile:
reader = csv.reader(csvfile, delimiter=';')
donnees = list(reader)
donnees = []
for row in reader:
# Ignorer les lignes de commentaire qui commencent par #
if row and not row[0].startswith('#'):
donnees.append(row)
# Si aucune donnée valide n'a été trouvée, retourner
if not donnees:
print("Aucune donnée valide trouvée dans le fichier CSV.")
return
# Récupérer les dates et les nombres de mots
dates = [datetime.fromisoformat(donnee[0]).date() for donnee in donnees]

654
generate_dashboard.py Executable file
View file

@ -0,0 +1,654 @@
#!/usr/bin/env python3
"""
Script pour générer un tableau de bord web pour le suivi d'un livre.
Ce script crée une page HTML dans le dossier 'build' avec des visualisations interactives:
- Graphique de réseau des personnages (déplaçable à la souris)
- Suivi de rédaction
- Graphique des intrigues
"""
import os
import csv
import json
import shutil
from datetime import datetime
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from scipy.interpolate import make_interp_spline
# Assurez-vous que le dossier build existe
BUILD_DIR = 'build'
if not os.path.exists(BUILD_DIR):
os.makedirs(BUILD_DIR)
# Créer le dossier pour les ressources statiques s'il n'existe pas
STATIC_DIR = os.path.join(BUILD_DIR, 'static')
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
# Créer les sous-dossiers pour CSS, JS et images
CSS_DIR = os.path.join(STATIC_DIR, 'css')
JS_DIR = os.path.join(STATIC_DIR, 'js')
IMG_DIR = os.path.join(STATIC_DIR, 'img')
for directory in [CSS_DIR, JS_DIR, IMG_DIR]:
if not os.path.exists(directory):
os.makedirs(directory)
def load_csv_data(file_path, delimiter=','):
"""Charge les données d'un fichier CSV."""
if not os.path.exists(file_path):
print(f"Erreur: Le fichier {file_path} n'existe pas.")
return []
with open(file_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f, delimiter=delimiter)
return list(reader)
def process_character_occurrences(csv_file='occurrences_personnages.csv'):
"""
Traite le fichier d'occurrences des personnages pour générer les données
du graphique de réseau.
"""
data = load_csv_data(csv_file)
if not data:
return None
# Extraire les en-têtes (noms des personnages)
headers = data[0]
characters = headers[1:] # Ignorer la première colonne (Chapitre)
# Créer les nœuds pour chaque personnage
nodes = [{"id": char, "name": char, "group": 1} for char in characters]
# Créer les liens entre personnages qui apparaissent dans les mêmes chapitres
links = []
for row in data[1:]: # Ignorer la ligne d'en-tête
if len(row) < len(headers):
continue
chapter = row[0]
# Trouver les personnages présents dans ce chapitre
present_chars = []
for i, count in enumerate(row[1:], 1):
if count and int(count) > 0:
present_chars.append(headers[i])
# Créer des liens entre tous les personnages présents
for i in range(len(present_chars)):
for j in range(i+1, len(present_chars)):
# Vérifier si le lien existe déjà
link_exists = False
for link in links:
if (link["source"] == present_chars[i] and link["target"] == present_chars[j]) or \
(link["source"] == present_chars[j] and link["target"] == present_chars[i]):
link["value"] += 1 # Incrémenter la force du lien
link_exists = True
break
# Si le lien n'existe pas, le créer
if not link_exists:
links.append({
"source": present_chars[i],
"target": present_chars[j],
"value": 1
})
return {"nodes": nodes, "links": links}
def process_writing_progress(csv_file='suivi_livre.csv'):
"""
Traite le fichier de suivi pour générer les données du graphique de progression.
"""
try:
# Lire le fichier CSV avec pandas pour faciliter le traitement des dates
df = pd.read_csv(csv_file, delimiter=';',
names=['date', 'mots', 'intrigues', 'personnages', 'personnages_mots', 'chapitres', 'sous_chapitres'])
# Convertir les dates
df['date'] = pd.to_datetime(df['date'])
df['date_str'] = df['date'].dt.strftime('%Y-%m-%d')
# Grouper par jour et prendre la dernière valeur de chaque jour
df = df.sort_values('date')
df = df.drop_duplicates('date_str', keep='last')
# Préparer les données pour le graphique
dates = df['date_str'].tolist()
words = df['mots'].tolist()
chapters = df['chapitres'].tolist()
return {
'dates': dates,
'words': words,
'chapters': chapters
}
except Exception as e:
print(f"Erreur lors du traitement du fichier de suivi: {e}")
return None
def process_plot_timeline(csv_file='intrigues.csv'):
"""
Traite le fichier d'intrigues pour générer les données du graphique de Gantt.
"""
data = load_csv_data(csv_file)
if not data or len(data) < 2:
return None
# Extraire les en-têtes
headers = data[0]
if len(headers) < 3:
print("Format de fichier d'intrigues invalide.")
return None
# Créer la structure de données pour le graphique de Gantt
plots = []
for row in data[1:]:
if len(row) < 3:
continue
try:
start = int(row[0])
end = int(row[1])
name = row[2]
plots.append({
'name': name,
'start': start,
'end': end
})
except (ValueError, IndexError) as e:
print(f"Erreur lors du traitement d'une ligne d'intrigue: {e}")
continue
return plots
def create_css():
"""Crée le fichier CSS pour le tableau de bord."""
css_content = """
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: #2c3e50;
color: white;
padding: 20px;
text-align: center;
margin-bottom: 30px;
}
h1, h2, h3 {
margin-top: 0;
}
.dashboard-section {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 30px;
}
.chart-container {
width: 100%;
height: 400px;
margin: 20px 0;
}
#network-graph {
border: 1px solid #ddd;
border-radius: 4px;
}
.stats-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.stat-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 15px;
width: calc(25% - 20px);
margin-bottom: 20px;
text-align: center;
}
.stat-card h3 {
margin-bottom: 5px;
font-size: 16px;
color: #666;
}
.stat-card .value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
@media (max-width: 768px) {
.stat-card {
width: calc(50% - 20px);
}
}
@media (max-width: 480px) {
.stat-card {
width: 100%;
}
}
footer {
text-align: center;
padding: 20px;
color: #666;
font-size: 14px;
}
"""
with open(os.path.join(CSS_DIR, 'dashboard.css'), 'w', encoding='utf-8') as f:
f.write(css_content)
def create_network_graph_js(network_data):
"""Crée le fichier JavaScript pour le graphique de réseau."""
js_content = f"""
// Données du graphique de réseau
const networkData = {json.dumps(network_data)};
// Fonction pour initialiser le graphique de réseau
function initNetworkGraph() {{
const width = document.getElementById('network-graph').clientWidth;
const height = 400;
// Créer le SVG
const svg = d3.select("#network-graph")
.append("svg")
.attr("width", width)
.attr("height", height);
// Créer la simulation de force
const simulation = d3.forceSimulation(networkData.nodes)
.force("link", d3.forceLink(networkData.links).id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(30));
// Créer les liens
const link = svg.append("g")
.attr("class", "links")
.selectAll("line")
.data(networkData.links)
.enter().append("line")
.attr("stroke-width", d => Math.sqrt(d.value))
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6);
// Créer les nœuds
const node = svg.append("g")
.attr("class", "nodes")
.selectAll("g")
.data(networkData.nodes)
.enter().append("g")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Ajouter des cercles pour les nœuds
node.append("circle")
.attr("r", 10)
.attr("fill", "#69b3a2");
// Ajouter des étiquettes pour les nœuds
node.append("text")
.text(d => d.name)
.attr("x", 12)
.attr("y", 3)
.style("font-size", "12px");
// Mettre à jour la position des éléments à chaque tick de la simulation
simulation.on("tick", () => {{
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
}});
// Fonctions pour le drag & drop
function dragstarted(event, d) {{
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}}
function dragged(event, d) {{
d.fx = event.x;
d.fy = event.y;
}}
function dragended(event, d) {{
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}}
}}
// Initialiser le graphique quand la page est chargée
document.addEventListener('DOMContentLoaded', initNetworkGraph);
"""
with open(os.path.join(JS_DIR, 'network-graph.js'), 'w', encoding='utf-8') as f:
f.write(js_content)
def create_progress_chart_js(progress_data):
"""Crée le fichier JavaScript pour le graphique de progression."""
js_content = f"""
// Données du graphique de progression
const progressData = {json.dumps(progress_data)};
// Fonction pour initialiser le graphique de progression
function initProgressChart() {{
const ctx = document.getElementById('progress-chart').getContext('2d');
const chart = new Chart(ctx, {{
type: 'line',
data: {{
labels: progressData.dates,
datasets: [
{{
label: 'Mots',
data: progressData.words,
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}},
{{
label: 'Chapitres',
data: progressData.chapters,
borderColor: '#e74c3c',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
yAxisID: 'y1'
}}
]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
scales: {{
x: {{
title: {{
display: true,
text: 'Date'
}}
}},
y: {{
title: {{
display: true,
text: 'Nombre de mots'
}},
beginAtZero: true
}},
y1: {{
position: 'right',
title: {{
display: true,
text: 'Nombre de chapitres'
}},
beginAtZero: true,
grid: {{
drawOnChartArea: false
}}
}}
}},
plugins: {{
tooltip: {{
mode: 'index',
intersect: false
}},
legend: {{
position: 'top'
}}
}}
}}
}});
}}
// Initialiser le graphique quand la page est chargée
document.addEventListener('DOMContentLoaded', initProgressChart);
"""
with open(os.path.join(JS_DIR, 'progress-chart.js'), 'w', encoding='utf-8') as f:
f.write(js_content)
def create_plot_timeline_js(plot_data):
"""Crée le fichier JavaScript pour le graphique des intrigues."""
js_content = f"""
// Données du graphique des intrigues
const plotData = {json.dumps(plot_data)};
// Fonction pour initialiser le graphique des intrigues
function initPlotTimeline() {{
const width = document.getElementById('plot-timeline').clientWidth;
const height = 400;
const margin = {{ top: 50, right: 50, bottom: 50, left: 150 }};
// Créer le SVG
const svg = d3.select("#plot-timeline")
.append("svg")
.attr("width", width)
.attr("height", height);
// Trouver les valeurs min et max pour l'échelle
const minStart = d3.min(plotData, d => d.start);
const maxEnd = d3.max(plotData, d => d.end);
// Créer les échelles
const xScale = d3.scaleLinear()
.domain([minStart, maxEnd])
.range([margin.left, width - margin.right]);
const yScale = d3.scaleBand()
.domain(plotData.map(d => d.name))
.range([margin.top, height - margin.bottom])
.padding(0.2);
// Créer les axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Ajouter les axes au SVG
svg.append("g")
.attr("transform", `translate(0,${{height - margin.bottom}})`)
.call(xAxis);
svg.append("g")
.attr("transform", `translate(${{margin.left}},0)`)
.call(yAxis);
// Ajouter les barres pour chaque intrigue
svg.selectAll(".plot-bar")
.data(plotData)
.enter()
.append("rect")
.attr("class", "plot-bar")
.attr("x", d => xScale(d.start))
.attr("y", d => yScale(d.name))
.attr("width", d => xScale(d.end) - xScale(d.start))
.attr("height", yScale.bandwidth())
.attr("fill", "#3498db")
.attr("opacity", 0.7)
.on("mouseover", function(event, d) {{
d3.select(this).attr("opacity", 1);
// Afficher une infobulle
svg.append("text")
.attr("class", "tooltip")
.attr("x", xScale(d.start) + (xScale(d.end) - xScale(d.start)) / 2)
.attr("y", yScale(d.name) - 5)
.attr("text-anchor", "middle")
.text(`${{d.name}}: ${{d.start}} - ${{d.end}}`)
.style("font-size", "12px")
.style("font-weight", "bold");
}})
.on("mouseout", function() {{
d3.select(this).attr("opacity", 0.7);
svg.selectAll(".tooltip").remove();
}});
// Ajouter un titre
svg.append("text")
.attr("x", width / 2)
.attr("y", margin.top / 2)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("Chronologie des intrigues");
}}
// Initialiser le graphique quand la page est chargée
document.addEventListener('DOMContentLoaded', initPlotTimeline);
"""
with open(os.path.join(JS_DIR, 'plot-timeline.js'), 'w', encoding='utf-8') as f:
f.write(js_content)
def create_dashboard_html(network_data, progress_data, plot_data):
"""Crée le fichier HTML pour le tableau de bord."""
# Calculer quelques statistiques pour afficher
total_words = progress_data['words'][-1] if progress_data['words'] else 0
total_chapters = progress_data['chapters'][-1] if progress_data['chapters'] else 0
total_characters = len(network_data['nodes']) if network_data else 0
total_plots = len(plot_data) if plot_data else 0
html_content = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de Bord du Livre</title>
<link rel="stylesheet" href="static/css/dashboard.css">
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<header>
<h1>Tableau de Bord du Livre</h1>
<p>Dernière mise à jour: {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
</header>
<div class="container">
<div class="dashboard-section">
<h2>Statistiques Générales</h2>
<div class="stats-container">
<div class="stat-card">
<h3>Mots</h3>
<div class="value">{total_words}</div>
</div>
<div class="stat-card">
<h3>Chapitres</h3>
<div class="value">{total_chapters}</div>
</div>
<div class="stat-card">
<h3>Personnages</h3>
<div class="value">{total_characters}</div>
</div>
<div class="stat-card">
<h3>Intrigues</h3>
<div class="value">{total_plots}</div>
</div>
</div>
</div>
<div class="dashboard-section">
<h2>Progression de l'Écriture</h2>
<div class="chart-container">
<canvas id="progress-chart"></canvas>
</div>
</div>
<div class="dashboard-section">
<h2>Réseau des Personnages</h2>
<p>Cliquez et faites glisser les nœuds pour réorganiser le graphique.</p>
<div class="chart-container" id="network-graph"></div>
</div>
<div class="dashboard-section">
<h2>Chronologie des Intrigues</h2>
<div class="chart-container" id="plot-timeline"></div>
</div>
</div>
<footer>
<p>Généré automatiquement par generate_dashboard.py</p>
</footer>
<script src="static/js/network-graph.js"></script>
<script src="static/js/progress-chart.js"></script>
<script src="static/js/plot-timeline.js"></script>
</body>
</html>
"""
with open(os.path.join(BUILD_DIR, 'dashboard.html'), 'w', encoding='utf-8') as f:
f.write(html_content)
def main():
"""Fonction principale pour générer le tableau de bord."""
print("Génération du tableau de bord...")
# Traiter les données
network_data = process_character_occurrences()
progress_data = process_writing_progress()
plot_data = process_plot_timeline()
# Vérifier si les données sont disponibles
if not network_data:
network_data = {"nodes": [], "links": []}
print("Avertissement: Données de réseau des personnages non disponibles.")
if not progress_data:
progress_data = {"dates": [], "words": [], "chapters": []}
print("Avertissement: Données de progression non disponibles.")
if not plot_data:
plot_data = []
print("Avertissement: Données d'intrigues non disponibles.")
# Créer les fichiers CSS et JS
create_css()
create_network_graph_js(network_data)
create_progress_chart_js(progress_data)
create_plot_timeline_js(plot_data)
# Créer le fichier HTML principal
create_dashboard_html(network_data, progress_data, plot_data)
print(f"Tableau de bord généré avec succès dans {os.path.join(BUILD_DIR, 'dashboard.html')}")
print("Ouvrez ce fichier dans votre navigateur pour voir le tableau de bord.")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After