1

Aère le graphique des nouveaux personnages

This commit is contained in:
2025-12-03 22:29:19 +01:00
parent f9854a6949
commit f9e1555ecb
7 changed files with 240 additions and 1 deletions

View File

@@ -1,12 +1,13 @@
"""Graphique du nombre de minifigs par personnage."""
from pathlib import Path
from typing import List
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
@@ -21,6 +22,11 @@ def load_presence(path: Path) -> List[dict]:
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)
@@ -199,3 +205,73 @@ def plot_character_year_presence(presence_path: Path, destination_path: 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)

View File

@@ -69,6 +69,47 @@ def aggregate_variations_and_totals(
return aggregates
def aggregate_new_characters_by_year(
minifigs_rows: Iterable[dict],
sets_years: Dict[str, str],
excluded_characters: Sequence[str] | None = None,
start_year: int | None = None,
end_year: int | None = None,
) -> List[dict]:
"""Compte le nombre de personnages introduits par année sur une plage donnée."""
excluded = set(excluded_characters or [])
first_year: Dict[str, int] = {}
for row in minifigs_rows:
character = row["known_character"].strip()
fig_num = row["fig_num"].strip()
if character == "" or fig_num == "":
continue
if character in excluded:
continue
year_str = sets_years.get(row["set_num"])
if year_str is None:
continue
year_int = int(year_str)
current = first_year.get(character)
if current is None or year_int < current:
first_year[character] = year_int
counts: Dict[int, int] = {}
if start_year is not None and end_year is not None:
for year in range(start_year, end_year + 1):
counts[year] = 0
for character, year_int in first_year.items():
if start_year is not None and year_int < start_year:
continue
if end_year is not None and year_int > end_year:
continue
counts[year_int] = counts.get(year_int, 0) + 1
years = sorted(counts.keys())
results: List[dict] = []
for year in years:
results.append({"year": str(year), "new_characters": str(counts[year])})
return results
def aggregate_by_gender(rows: Iterable[dict]) -> List[dict]:
"""Compte les minifigs distinctes par genre (fig_num unique)."""
genders_by_fig: Dict[str, str] = {}
@@ -102,6 +143,17 @@ def write_character_counts(path: Path, rows: Sequence[dict]) -> None:
writer.writerow(row)
def write_new_characters_by_year(path: Path, rows: Sequence[dict]) -> None:
"""Écrit le CSV des personnages introduits chaque année."""
ensure_parent_dir(path)
fieldnames = ["year", "new_characters"]
with path.open("w", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
def write_gender_counts(path: Path, rows: Sequence[dict]) -> None:
"""Écrit le CSV des comptes par genre."""
ensure_parent_dir(path)