#!/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"""
Dernière mise à jour: {datetime.now().strftime('%Y-%m-%d %H:%M')}
Cliquez et faites glisser les nœuds pour réorganiser le graphique.