1
etude_lego_jurassic_world/lib/plots/minifig_characters.py

278 lines
10 KiB
Python

"""Graphique du nombre de minifigs par personnage."""
from pathlib import Path
from typing import Dict, List
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from lib.filesystem import ensure_parent_dir
from lib.milestones import load_milestones
from lib.plots.gender_palette import GENDER_COLORS, GENDER_LABELS
from lib.rebrickable.stats import read_rows
def load_counts(path: Path) -> List[dict]:
"""Charge le CSV des comptes par personnage."""
return read_rows(path)
def load_presence(path: Path) -> List[dict]:
"""Charge le CSV de présence par année/personnage."""
return read_rows(path)
def load_new_characters(path: Path) -> List[dict]:
"""Charge le CSV des personnages introduits par année."""
return read_rows(path)
def load_variations_and_totals(path: Path) -> List[dict]:
"""Charge le CSV comparatif variations/total par personnage."""
return read_rows(path)
def plot_minifigs_per_character(counts_path: Path, destination_path: Path) -> None:
"""Trace un diagramme en barres horizontales du nombre de minifigs par personnage."""
rows = load_counts(counts_path)
characters = [row["known_character"] for row in rows]
counts = [int(row["minifig_count"]) for row in rows]
genders = [row.get("gender", "") for row in rows]
colors = [GENDER_COLORS.get(gender.strip().lower(), GENDER_COLORS["unknown"]) for gender in genders]
positions = list(range(len(rows)))
height = max(6, len(rows) * 0.22)
fig, ax = plt.subplots(figsize=(12, height))
bars = ax.barh(positions, counts, color=colors, edgecolor="#0d0d0d", linewidth=0.6)
ax.set_yticks(positions)
ax.set_yticklabels(characters)
ax.invert_yaxis()
ax.set_xlabel("Nombre de minifigs distinctes")
ax.set_title("Minifigs par personnage (thèmes filtrés)")
ax.grid(True, axis="x", linestyle="--", alpha=0.25)
max_value = max(counts) if counts else 0
ax.set_xlim(0, max_value + 1)
for index, bar in enumerate(bars):
value = counts[index]
ax.text(value + 0.1, bar.get_y() + bar.get_height() / 2, str(value), va="center", fontsize=8)
legend_entries = []
seen = set()
for gender in genders:
normalized = gender.strip().lower()
if normalized in seen:
continue
seen.add(normalized)
legend_entries.append(
Patch(
facecolor=GENDER_COLORS.get(normalized, GENDER_COLORS["unknown"]),
edgecolor="#0d0d0d",
linewidth=0.6,
label=GENDER_LABELS.get(normalized, "Inconnu"),
)
)
if legend_entries:
ax.legend(handles=legend_entries, title="Genre", loc="lower right")
ensure_parent_dir(destination_path)
fig.tight_layout()
fig.savefig(destination_path, dpi=160)
plt.close(fig)
def plot_character_variations_vs_total(counts_path: Path, destination_path: Path) -> None:
"""Superpose le total de minifigs et leurs variations distinctes par personnage."""
rows = load_variations_and_totals(counts_path)
if not rows:
return
characters = [row["known_character"] for row in rows]
variation_counts = [int(row["variation_count"]) for row in rows]
total_counts = [int(row["total_minifigs"]) for row in rows]
genders = [row.get("gender", "") for row in rows]
gender_colors = [GENDER_COLORS.get(gender.strip().lower(), GENDER_COLORS["unknown"]) for gender in genders]
positions = list(range(len(rows)))
height = max(6, len(rows) * 0.24)
background_color = "#d7d7e0"
fig, ax = plt.subplots(figsize=(12.4, height))
bars_total = ax.barh(
positions,
total_counts,
color=background_color,
edgecolor="#0d0d0d",
linewidth=0.6,
height=0.6,
label="Total de minifigs",
)
bars_variations = ax.barh(
positions,
variation_counts,
color=gender_colors,
edgecolor="#0d0d0d",
linewidth=0.8,
height=0.36,
label="Variations distinctes",
)
ax.set_yticks(positions)
ax.set_yticklabels(characters)
ax.invert_yaxis()
ax.set_xlabel("Nombre de minifigs")
ax.set_title("Variations et total de minifigs par personnage (hors figurants)")
ax.grid(True, axis="x", linestyle="--", alpha=0.25)
max_value = max(total_counts) if total_counts else 0
ax.set_xlim(0, max_value + 1)
for index, bar in enumerate(bars_total):
value = total_counts[index]
ax.text(value + 0.12, bar.get_y() + bar.get_height() / 2, str(value), va="center", fontsize=8, color="#1a1a1a")
for index, bar in enumerate(bars_variations):
value = variation_counts[index]
ax.text(value + 0.12, bar.get_y() + bar.get_height() / 2, str(value), va="center", fontsize=8, color="#0d0d0d")
legend_entries = [
Patch(facecolor=background_color, edgecolor="#0d0d0d", linewidth=0.6, label="Total de minifigs"),
Patch(
facecolor=GENDER_COLORS["unknown"],
edgecolor="#0d0d0d",
linewidth=0.8,
label="Variations distinctes (couleur = genre)",
),
]
seen = set()
for gender, color in zip(genders, gender_colors):
normalized = gender.strip().lower()
if normalized in seen:
continue
seen.add(normalized)
legend_entries.append(
Patch(
facecolor=color,
edgecolor="#0d0d0d",
linewidth=0.6,
label=GENDER_LABELS.get(normalized, "Inconnu"),
)
)
ax.legend(handles=legend_entries, loc="lower right")
ensure_parent_dir(destination_path)
fig.tight_layout()
fig.savefig(destination_path, dpi=160)
plt.close(fig)
def plot_character_year_presence(presence_path: Path, destination_path: Path) -> None:
"""Trace une heatmap indiquant le nombre de minifigs par personnage et par année."""
rows = load_presence(presence_path)
if not rows:
return
years = sorted({int(row["year"]) for row in rows})
characters = sorted(
{row["known_character"] for row in rows},
key=lambda name: (
-sum(int(r["minifig_count"]) for r in rows if r["known_character"] == name),
name,
),
)
matrix = []
for character in characters:
row_values = []
for year in years:
count = next(
(r["minifig_count"] for r in rows if r["known_character"] == character and int(r["year"]) == year),
"0",
)
row_values.append(int(count))
matrix.append(row_values)
height = max(5, len(characters) * 0.35)
fig, ax = plt.subplots(figsize=(12, height))
cax = ax.imshow(matrix, aspect="auto", cmap="Greens", interpolation="nearest")
ax.set_xticks(range(len(years)))
ax.set_xticklabels(years, rotation=45, ha="right")
ax.set_yticks(range(len(characters)))
ax.set_yticklabels(characters)
ax.set_xlabel("Année")
ax.set_ylabel("Personnage")
ax.set_title("Nombre de minifigs par personnage et par année (hors figurants)")
for i, character in enumerate(characters):
for j, year in enumerate(years):
value = matrix[i][j]
if value == 1:
ax.text(j, i, "", ha="center", va="center", color="#0d0d0d", fontsize=7)
elif value > 1:
ax.text(j, i, str(value), ha="center", va="center", color="#0d0d0d", fontsize=7)
fig.colorbar(cax, ax=ax, fraction=0.046, pad=0.04, label="Nombre de minifigs")
ensure_parent_dir(destination_path)
fig.tight_layout()
fig.savefig(destination_path, dpi=160)
plt.close(fig)
def plot_new_characters_per_year(
counts_path: Path,
milestones_path: Path,
destination_path: Path,
start_year: int,
end_year: int,
) -> None:
"""Trace un diagramme en barres du nombre de nouveaux personnages introduits par an."""
rows = load_new_characters(counts_path)
if not rows:
return
counts = {int(row["year"]): int(row["new_characters"]) for row in rows}
years = list(range(start_year, end_year + 1))
values = [counts.get(year, 0) for year in years]
fig_width = max(8.5, len(years) * 0.45 + 2.5)
fig, ax = plt.subplots(figsize=(fig_width, 5.4))
bars = ax.bar(years, values, color="#1f77b4", edgecolor="#0d0d0d", linewidth=0.7)
ax.set_xlabel("Année")
ax.set_ylabel("Nouveaux personnages")
ax.set_title("Personnages introduits par an (hors figurants)")
ax.grid(axis="y", linestyle="--", alpha=0.3)
ax.set_xticks(years)
ax.set_xticklabels(years, rotation=45, ha="right")
ax.set_xlim(start_year - 0.6, end_year + 0.6)
y_max = max(values) if values else 0
upper_limit = 20
ax.set_ylim(0, upper_limit)
for bar, value in zip(bars, values):
if value == 0:
continue
ax.text(bar.get_x() + bar.get_width() / 2, value + 0.05, str(value), ha="center", va="bottom", fontsize=8)
milestones = load_milestones(milestones_path)
if milestones:
milestones_in_range = sorted(
[m for m in milestones if start_year <= m["year"] <= end_year],
key=lambda m: (m["year"], m["description"]),
)
offset_step = 0.25
offset_map: Dict[int, int] = {}
top_limit = ax.get_ylim()[1]
label_y = top_limit * 0.96
for milestone in milestones_in_range:
year = milestone["year"]
count_for_year = offset_map.get(year, 0)
offset_map[year] = count_for_year + 1
horizontal_offset = offset_step * (count_for_year // 2 + 1)
if count_for_year % 2 == 1:
horizontal_offset *= -1
text_x = year + horizontal_offset
ax.axvline(year, color="#d62728", linestyle="--", linewidth=1, alpha=0.65, zorder=1)
ax.text(
text_x,
label_y,
milestone["description"],
rotation=90,
verticalalignment="top",
horizontalalignment="center",
fontsize=8,
color="#d62728",
)
ensure_parent_dir(destination_path)
fig.tight_layout()
fig.savefig(destination_path, dpi=160)
plt.close(fig)