957 lines
No EOL
33 KiB
Python
Executable file
957 lines
No EOL
33 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_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"""<!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>Réseau Personnages-Intrigues</h2>
|
|
<p>Cliquez et faites glisser les nœuds pour réorganiser le graphique. Survolez les nœuds d'intrigue pour voir leur nom.</p>
|
|
<div class="chart-container" id="character-plot-network"></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/character-plot-network.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()
|
|
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() |