book-generator-orgmode/generate_dashboard.py
2025-08-30 18:20:50 +02:00

654 lines
No EOL
21 KiB
Python
Executable file

#!/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()