#!/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_character_plot_network(char_csv='occurrences_personnages.csv', plot_csv='intrigues.csv'): """ Traite les fichiers d'occurrences des personnages et d'intrigues pour générer les données du graphique de réseau reliant personnages et intrigues. """ # Charger les données des personnages char_data = load_csv_data(char_csv) if not char_data: return None # Charger les données des intrigues plot_data = load_csv_data(plot_csv) if not plot_data or len(plot_data) < 2: return None # Extraire les en-têtes (noms des personnages) char_headers = char_data[0] characters = char_headers[1:] # Ignorer la première colonne (Chapitre) # Créer un dictionnaire pour stocker les chapitres où chaque personnage apparaît character_chapters = {char: [] for char in characters} # Remplir le dictionnaire avec les chapitres où chaque personnage apparaît for row in char_data[1:]: # Ignorer la ligne d'en-tête if len(row) < len(char_headers): continue chapter = row[0] # Extraire le numéro de chapitre si possible chapter_num = None try: # Essayer d'extraire un nombre du nom du chapitre import re match = re.search(r'(\d+)', chapter) if match: chapter_num = int(match.group(1)) except: pass # Si on n'a pas pu extraire un nombre, continuer if chapter_num is None: continue # Trouver les personnages présents dans ce chapitre for i, count in enumerate(row[1:], 1): if count and int(count) > 0: character_chapters[char_headers[i]].append(chapter_num) # Créer les nœuds pour chaque personnage (groupe 1) nodes = [{"id": char, "name": char, "group": 1, "type": "character"} for char in characters] # Créer les nœuds pour chaque intrigue (groupe 2) for row in plot_data[1:]: # Ignorer la ligne d'en-tête if len(row) < 3: continue try: start = int(row[0]) end = int(row[1]) name = row[2] nodes.append({ "id": f"plot_{name}", "name": name, "group": 2, "type": "plot", "start": start, "end": end }) except (ValueError, IndexError): continue # Créer les liens entre personnages et intrigues links = [] # Pour chaque intrigue for node in nodes: if node["group"] == 2: # C'est une intrigue plot_start = node["start"] plot_end = node["end"] # Pour chaque personnage for char_node in nodes: if char_node["group"] == 1: # C'est un personnage char_name = char_node["id"] # Vérifier si le personnage apparaît dans un chapitre couvert par l'intrigue appears_in_plot = False for chapter in character_chapters[char_name]: if plot_start <= chapter <= plot_end: appears_in_plot = True break # Si le personnage apparaît dans l'intrigue, créer un lien if appears_in_plot: links.append({ "source": char_name, "target": node["id"], "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=';', comment='#', 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, #character-plot-network, #plot-timeline { 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; } /* Styles pour le graphique de réseau personnages-intrigues */ .character text { font-weight: bold; fill: #333; } .plot text { fill: #e74c3c; } .legend { font-size: 12px; } @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_character_plot_network_js(network_data): """Crée le fichier JavaScript pour le graphique de réseau personnages-intrigues.""" js_content = f""" // Données du graphique de réseau personnages-intrigues const characterPlotData = {json.dumps(network_data)}; // Fonction pour initialiser le graphique de réseau personnages-intrigues function initCharacterPlotNetwork() {{ const width = document.getElementById('character-plot-network').clientWidth; const height = 400; // Créer le SVG const svg = d3.select("#character-plot-network") .append("svg") .attr("width", width) .attr("height", height); // Définir les couleurs pour les différents types de nœuds const nodeColors = {{ character: "#69b3a2", // Vert pour les personnages plot: "#e74c3c" // Rouge pour les intrigues }}; // Créer la simulation de force const simulation = d3.forceSimulation(characterPlotData.nodes) .force("link", d3.forceLink(characterPlotData.links).id(d => d.id).distance(150)) .force("charge", d3.forceManyBody().strength(-300)) .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(characterPlotData.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(characterPlotData.nodes) .enter().append("g") .attr("class", d => d.type) .call(d3.drag() .on("start", dragstarted) .on("drag", dragged) .on("end", dragended)); // Ajouter des cercles pour les nœuds node.append("circle") .attr("r", d => d.type === "character" ? 8 : 12) .attr("fill", d => nodeColors[d.type]); // Ajouter des étiquettes pour les nœuds // Les étiquettes des personnages sont toujours visibles node.filter(d => d.type === "character") .append("text") .text(d => d.name) .attr("x", 12) .attr("y", 3) .style("font-size", "12px") .style("font-weight", "bold"); // Les étiquettes des intrigues ne sont visibles que lors du survol const plotLabels = node.filter(d => d.type === "plot") .append("text") .text(d => d.name) .attr("x", 12) .attr("y", 3) .style("font-size", "12px") .style("font-weight", "bold") .style("fill", "#e74c3c") .style("opacity", 0); // Initialement invisible // Ajouter des événements de survol pour les nœuds d'intrigue node.filter(d => d.type === "plot") .on("mouseover", function(event, d) {{ // Rendre l'étiquette visible d3.select(this).select("text").style("opacity", 1); // Mettre en évidence les liens connectés link.style("stroke", l => l.source.id === d.id || l.target.id === d.id ? "#e74c3c" : "#999") .style("stroke-opacity", l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.2) .style("stroke-width", l => l.source.id === d.id || l.target.id === d.id ? Math.sqrt(l.value) * 2 : Math.sqrt(l.value)); // Mettre en évidence les personnages connectés node.style("opacity", n => n.id === d.id || characterPlotData.links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.4); }}) .on("mouseout", function() {{ // Cacher l'étiquette d3.select(this).select("text").style("opacity", 0); // Restaurer les liens link.style("stroke", "#999") .style("stroke-opacity", 0.6) .style("stroke-width", d => Math.sqrt(d.value)); // Restaurer les nœuds node.style("opacity", 1); }}); // 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; }} // Ajouter une légende const legend = svg.append("g") .attr("class", "legend") .attr("transform", "translate(20, 20)"); // Légende pour les personnages legend.append("circle") .attr("r", 6) .attr("fill", nodeColors.character); legend.append("text") .attr("x", 15) .attr("y", 4) .text("Personnages") .style("font-size", "12px"); // Légende pour les intrigues legend.append("circle") .attr("r", 6) .attr("fill", nodeColors.plot) .attr("transform", "translate(0, 20)"); legend.append("text") .attr("x", 15) .attr("y", 24) .text("Intrigues (survoler pour voir le nom)") .style("font-size", "12px"); }} // Initialiser le graphique quand la page est chargée document.addEventListener('DOMContentLoaded', initCharacterPlotNetwork); """ with open(os.path.join(JS_DIR, 'character-plot-network.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, character_plot_data=None): """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.

Réseau Personnages-Intrigues

Cliquez et faites glisser les nœuds pour réorganiser le graphique. Survolez les nœuds d'intrigue pour voir leur nom.

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() character_plot_data = process_character_plot_network() # 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.") if not character_plot_data: character_plot_data = {"nodes": [], "links": []} print("Avertissement: Données de réseau personnages-intrigues non disponibles.") # Créer les fichiers CSS et JS create_css() create_network_graph_js(network_data) create_character_plot_network_js(character_plot_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, character_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()