134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""Visualisations des têtes dual-face."""
|
|
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Tuple
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
from matplotlib.patches import Patch
|
|
|
|
from lib.filesystem import ensure_parent_dir
|
|
from lib.rebrickable.stats import read_rows
|
|
|
|
|
|
def load_rows(path: Path) -> List[dict]:
|
|
"""Charge un CSV en mémoire."""
|
|
return read_rows(path)
|
|
|
|
|
|
def plot_dual_faces_timeline(by_year_path: Path, destination_path: Path) -> None:
|
|
"""Trace la part annuelle des têtes dual-face."""
|
|
rows = load_rows(by_year_path)
|
|
if not rows:
|
|
return
|
|
years = [row["year"] for row in rows]
|
|
totals = [int(row["total_heads"]) for row in rows]
|
|
duals = [int(row["dual_heads"]) for row in rows]
|
|
shares = [float(row["share_dual"]) for row in rows]
|
|
x = np.arange(len(years))
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
ax.bar(x, totals, color="#dddddd", alpha=0.4, label="Têtes totales")
|
|
ax.plot(x, duals, color="#1f77b4", linewidth=2.0, label="Têtes dual-face (volume)")
|
|
ax.plot(x, [s * max(totals) for s in shares], color="#d62728", linestyle="--", linewidth=1.6, label="Part dual-face (échelle volume)")
|
|
ax.set_xticks(x)
|
|
ax.set_xticklabels(years, rotation=45, ha="right")
|
|
ax.set_ylabel("Volume de têtes")
|
|
ax.set_title("Têtes de minifigs : volume et part des dual-face par année")
|
|
ax.grid(True, linestyle="--", alpha=0.3)
|
|
ax.legend(loc="upper left", frameon=False)
|
|
|
|
ensure_parent_dir(destination_path)
|
|
fig.tight_layout()
|
|
fig.savefig(destination_path, dpi=170)
|
|
plt.close(fig)
|
|
|
|
|
|
def select_top_sets(rows: Iterable[dict], limit: int = 15) -> List[dict]:
|
|
"""Sélectionne les sets avec le plus de têtes dual-face."""
|
|
sorted_rows = sorted(
|
|
rows,
|
|
key=lambda row: (-int(row["dual_heads"]), -float(row["share_dual"]), row["set_num"]),
|
|
)
|
|
return sorted_rows[:limit]
|
|
|
|
|
|
def plot_dual_faces_top_sets(by_set_path: Path, destination_path: Path) -> None:
|
|
"""Top des sets contenant des têtes dual-face."""
|
|
rows = load_rows(by_set_path)
|
|
if not rows:
|
|
return
|
|
top_rows = select_top_sets(rows)
|
|
y = np.arange(len(top_rows))
|
|
duals = [int(row["dual_heads"]) for row in top_rows]
|
|
labels = [f"{row['set_num']} · {row['name']} ({row['year']})" for row in top_rows]
|
|
owned_mask = [row["in_collection"] == "true" for row in top_rows]
|
|
|
|
fig, ax = plt.subplots(figsize=(11, 8))
|
|
for pos, value, owned in zip(y, duals, owned_mask):
|
|
alpha = 0.9 if owned else 0.45
|
|
ax.barh(pos, value, color="#9467bd", alpha=alpha)
|
|
ax.set_yticks(y)
|
|
ax.set_yticklabels(labels)
|
|
ax.invert_yaxis()
|
|
ax.set_xlabel("Nombre de têtes dual-face")
|
|
ax.set_title("Top des sets avec têtes dual-face")
|
|
ax.grid(axis="x", linestyle="--", alpha=0.3)
|
|
legend = [
|
|
Patch(facecolor="#9467bd", edgecolor="none", alpha=0.9, label="Set possédé"),
|
|
Patch(facecolor="#9467bd", edgecolor="none", alpha=0.45, label="Set manquant"),
|
|
]
|
|
ax.legend(handles=legend, loc="lower right", frameon=False)
|
|
|
|
ensure_parent_dir(destination_path)
|
|
fig.tight_layout()
|
|
fig.savefig(destination_path, dpi=170)
|
|
plt.close(fig)
|
|
|
|
|
|
def select_top_characters(rows: Iterable[dict], limit: int = 12) -> List[dict]:
|
|
"""Sélectionne les personnages avec le plus de têtes dual-face."""
|
|
sorted_rows = sorted(
|
|
rows,
|
|
key=lambda row: (-int(row["dual_heads"]), -float(row["share_dual"]), row["known_character"]),
|
|
)
|
|
return sorted_rows[:limit]
|
|
|
|
|
|
def plot_dual_faces_characters(by_character_path: Path, destination_path: Path) -> None:
|
|
"""Top des personnages illustrés par des têtes dual-face."""
|
|
rows = load_rows(by_character_path)
|
|
if not rows:
|
|
return
|
|
top_rows = select_top_characters(rows)
|
|
y = np.arange(len(top_rows))
|
|
duals = [int(row["dual_heads"]) for row in top_rows]
|
|
totals = [int(row["total_heads"]) for row in top_rows]
|
|
shares = [float(row["share_dual"]) for row in top_rows]
|
|
labels = [row["known_character"] for row in top_rows]
|
|
|
|
fig, ax = plt.subplots(figsize=(11, 8))
|
|
ax.barh(y, totals, color="#cccccc", alpha=0.4, label="Têtes totales")
|
|
ax.barh(y, duals, color="#e15759", alpha=0.9, label="Têtes dual-face")
|
|
for pos, share in zip(y, shares):
|
|
ax.text(
|
|
totals[pos] + 0.1,
|
|
pos,
|
|
f"{share*100:.1f}%",
|
|
va="center",
|
|
ha="left",
|
|
fontsize=9,
|
|
color="#333333",
|
|
)
|
|
ax.set_yticks(y)
|
|
ax.set_yticklabels(labels)
|
|
ax.invert_yaxis()
|
|
ax.set_xlabel("Nombre de têtes")
|
|
ax.set_title("Personnages dotés de têtes dual-face")
|
|
ax.grid(axis="x", linestyle="--", alpha=0.3)
|
|
ax.legend(loc="lower right", frameon=False)
|
|
|
|
ensure_parent_dir(destination_path)
|
|
fig.tight_layout()
|
|
fig.savefig(destination_path, dpi=170)
|
|
plt.close(fig)
|