diff --git a/README.md b/README.md index 1d26e04..2f7d962 100644 --- a/README.md +++ b/README.md @@ -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+ diff --git a/dashboard_instructions.md b/dashboard_instructions.md new file mode 100644 index 0000000..06ecfba --- /dev/null +++ b/dashboard_instructions.md @@ -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 \ No newline at end of file diff --git a/fix_csv_execution.py b/fix_csv_execution.py new file mode 100644 index 0000000..53af166 --- /dev/null +++ b/fix_csv_execution.py @@ -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() \ No newline at end of file diff --git a/follow_progress.py b/follow_progress.py index 50f2bd9..993971c 100755 --- a/follow_progress.py +++ b/follow_progress.py @@ -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] diff --git a/generate_dashboard.py b/generate_dashboard.py new file mode 100755 index 0000000..1e7e436 --- /dev/null +++ b/generate_dashboard.py @@ -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""" + + + + + Tableau de Bord du Livre + + + + + +
+

Tableau de Bord du Livre

+

Dernière mise à jour: {datetime.now().strftime('%Y-%m-%d %H:%M')}

+
+ +
+
+

Statistiques Générales

+
+
+

Mots

+
{total_words}
+
+
+

Chapitres

+
{total_chapters}
+
+
+

Personnages

+
{total_characters}
+
+
+

Intrigues

+
{total_plots}
+
+
+
+ +
+

Progression de l'Écriture

+
+ +
+
+ +
+

Réseau des Personnages

+

Cliquez et faites glisser les nœuds pour réorganiser le graphique.

+
+
+ +
+

Chronologie des Intrigues

+
+
+
+ + + + + + + + +""" + + 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() \ No newline at end of file diff --git a/graphique_gantt_intrigues.png b/graphique_gantt_intrigues.png index bbc24fa..23bfe6c 100644 Binary files a/graphique_gantt_intrigues.png and b/graphique_gantt_intrigues.png differ