""" 複数アルバム比較レーダーチャート生成。 軸配置は本体プロンプト §11 固定: 12時 C / 3時 R / 6時 D / 9時 S, 時計回り usage: python generate_comparison_chart.py --albums take_care,views,scorpion,for_all_the_dogs,gnx """ from __future__ import annotations import argparse import base64 import json import sys from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parent)) from compute_hpci import ALBUM_REGISTRY, format_value # noqa: E402 # アーティスト別 / 年代別カラーパレット COLORS = [ "#1f77b4", # Drake Take Care (2011) - blue "#2ca02c", # Drake Views (2016) - green "#ff7f0e", # Drake Scorpion (2018) - orange "#9467bd", # Drake For All the Dogs (2023) - purple "#d62728", # Kendrick Lamar GNX (2024) - red "#000000", # Drake Iceman (2026) - black (反映時) "#7f7f7f", # spare ] def generate_comparison(album_slugs: list[str], out_png: Path, out_base64: Path | None, results_dir: Path | None = None) -> None: project_root = Path(__file__).resolve().parent.parent results_dir = results_dir or project_root / "data" / "credits" results: list[dict] = [] for slug in album_slugs: json_path = results_dir / f"{slug}.json" if not json_path.exists(): raise FileNotFoundError(f"JSON not found for '{slug}': {json_path}") results.append(json.loads(json_path.read_text(encoding="utf-8"))) labels = ["C\nConcentration", "R\nTop-5 concentration", "D\nDensity", "S\nSelf-involvement"] n = len(labels) theta = [2 * np.pi * i / n for i in range(n)] theta_closed = theta + [theta[0]] fig, ax = plt.subplots( figsize=(13, 13), dpi=100, subplot_kw={"projection": "polar"}, ) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.spines["polar"].set_visible(False) ax.set_ylim(0, 130) ax.set_yticks([20, 40, 60, 80, 100]) ax.set_yticklabels(["20", "40", "60", "80", "100"], fontsize=9, color="#666") ax.set_rlabel_position(45) for idx, r in enumerate(results): a = r["axes_normalized"] values = [ a["C_axis_normalized"], a["R_axis_normalized"], a["D_axis_normalized"], a["S_axis_normalized"], ] values_closed = values + [values[0]] color = COLORS[idx % len(COLORS)] legend_label = f"{r['title']} ({r['release_year']}) — {r['artist']}" ax.plot(theta_closed, values_closed, linewidth=2.2, color=color, label=legend_label, alpha=0.85) ax.fill(theta_closed, values_closed, alpha=0.08, color=color) # 軸ラベル for angle, label in zip(theta, labels): alignment = "left" if label.startswith("R") else "right" if label.startswith("S") else "center" ax.text(angle, 115, label, ha=alignment, va="center", fontsize=12, fontweight="bold") ax.set_xticks(theta) ax.set_xticklabels([""] * n) fig.suptitle( "HPCI Production Constellation Index — Album Comparison", fontsize=18, fontweight="bold", y=0.96, ) ax.set_title("4-axis: C/R/D/S, normalized 0–100", fontsize=11, color="#555", pad=30) # 凡例 (チャート外側) ax.legend( loc="upper left", bbox_to_anchor=(1.05, 1.0), fontsize=10, frameon=True, framealpha=0.95, ) # 数値表 (右下) # 2026-09-10: 本文の丸め規則(小数第2位までROUND_HALF_UP)に合わせて表示桁を統一 # (Codex監査r2 finding#8対応。旧版はCを整数、R/D/Sを小数1桁で表示していた)。 table_text_lines = [" Album C R D S "] table_text_lines.append(" " + "-" * 55) for r in results: raw = r["axes"] title_short = (r["title"][:18]).ljust(18) line = f" {title_short} {format_value(raw['C_axis']):>8} {format_value(raw['R_axis']):>6} {format_value(raw['D_axis']):>6} {format_value(raw['S_axis']):>6}" table_text_lines.append(line) fig.text(0.5, -0.02, "\n".join(table_text_lines), ha="center", fontsize=9, family="monospace", color="#333") fig.text(0.5, -0.10, "HIPHOPCs / hpci_constellation", ha="center", fontsize=8, color="#999") out_png.parent.mkdir(parents=True, exist_ok=True) fig.savefig(out_png, dpi=100, bbox_inches="tight", facecolor="white") plt.close(fig) if out_base64: with out_png.open("rb") as f: b64 = base64.b64encode(f.read()).decode("ascii") out_base64.write_text(f"data:image/png;base64,{b64}\n", encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--albums", required=True, help="カンマ区切りのalbum_slug") parser.add_argument("--out-png", default=None) parser.add_argument("--out-base64", default=None) parser.add_argument("--results-dir", type=Path, help="算出JSONのディレクトリ。公開パッケージではresults") parser.add_argument("--name", default="comparison", help="出力ファイル名のサフィックス") args = parser.parse_args() slugs = [s.strip() for s in args.albums.split(",") if s.strip()] for slug in slugs: if slug not in ALBUM_REGISTRY: print( f"ERROR: album '{slug}' is not in ALBUM_REGISTRY.\n" f"Available: {sorted(ALBUM_REGISTRY.keys())}", file=sys.stderr, ) return 2 project_root = Path(__file__).resolve().parent.parent out_png = Path(args.out_png) if args.out_png else project_root / "output" / f"{args.name}_radar.png" out_base64 = Path(args.out_base64) if args.out_base64 else project_root / "output" / f"{args.name}_radar_base64.txt" generate_comparison(slugs, out_png, out_base64, args.results_dir) print(f"✓ Comparison Chart: {out_png}") print(f"✓ Base64: {out_base64}") return 0 if __name__ == "__main__": sys.exit(main())