| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- """Plot styling helpers for publication-quality static charts."""
- from __future__ import annotations
- import logging
- import re
- import textwrap
- import unicodedata
- import warnings
- from pathlib import Path
- from typing import Iterable, Sequence
- def configure_plotting_backend():
- """Configure a non-interactive matplotlib backend and return plotting modules."""
- import matplotlib
- current_backend = matplotlib.get_backend().lower()
- if "agg" not in current_backend:
- matplotlib.use("Agg", force=True)
- logging.getLogger("matplotlib.category").setLevel(logging.WARNING)
- import matplotlib.pyplot as plt
- import seaborn as sns
- return plt, sns
- def get_plot_font_family() -> str:
- """Return the best available CJK-capable font family for the local machine."""
- configure_plotting_backend()
- from matplotlib import font_manager
- preferred_families = [
- "Microsoft YaHei",
- "Noto Sans SC",
- "SimHei",
- "SimSun",
- "Arial Unicode MS",
- "DejaVu Sans",
- ]
- available = {font.name for font in font_manager.fontManager.ttflist}
- for family in preferred_families:
- if family in available:
- return family
- return "DejaVu Sans"
- def apply_publication_style():
- """Apply a consistent scientific plotting style with Chinese-safe fonts."""
- plt, sns = configure_plotting_backend()
- font_family = get_plot_font_family()
- sns.set_theme(context="talk", style="whitegrid", palette="deep")
- plt.rcParams.update(
- {
- "figure.figsize": (10.5, 6.2),
- "figure.dpi": 140,
- # Keep layout control conservative here; save_figure() owns final save-time fallback.
- "figure.constrained_layout.use": False,
- "savefig.dpi": 300,
- "savefig.bbox": "tight",
- "savefig.facecolor": "white",
- "axes.facecolor": "#FAFAFA",
- "axes.edgecolor": "#2F2F2F",
- "axes.labelcolor": "#1F1F1F",
- "axes.titleweight": "bold",
- "axes.titlesize": 16,
- "axes.labelsize": 12,
- "axes.linewidth": 1.0,
- "axes.spines.top": False,
- "axes.spines.right": False,
- "grid.alpha": 0.18,
- "grid.linestyle": "--",
- "grid.linewidth": 0.8,
- "legend.frameon": False,
- "legend.fontsize": 10,
- "legend.title_fontsize": 11,
- "lines.linewidth": 2.2,
- "lines.markersize": 6,
- "xtick.color": "#333333",
- "ytick.color": "#333333",
- "xtick.labelsize": 10,
- "ytick.labelsize": 10,
- "font.family": "sans-serif",
- "font.sans-serif": [font_family, "Microsoft YaHei", "Noto Sans SC", "SimHei", "DejaVu Sans"],
- "axes.unicode_minus": False,
- }
- )
- return plt, sns
- def ensure_ascii_text(value: object, fallback: str = "label") -> str:
- """Convert labels to ASCII-only text when a fully ASCII figure is desired."""
- text = str(value).strip()
- normalized = unicodedata.normalize("NFKD", text)
- ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
- compact_text = " ".join(ascii_text.split()).strip()
- return compact_text or fallback
- def ensure_ascii_sequence(values: Iterable[object], prefix: str = "label") -> list[str]:
- """Convert a sequence of labels to ASCII-only strings."""
- converted: list[str] = []
- for index, value in enumerate(values, start=1):
- converted.append(ensure_ascii_text(value, fallback=f"{prefix}_{index}"))
- return converted
- def prepare_month_index(values: Sequence[object]):
- """Convert Chinese or ISO-like month labels to a stable datetime index when possible."""
- import pandas as pd
- normalized_values = []
- for value in values:
- text = str(value).strip()
- normalized_text = text.replace("年", "-").replace("月", "").replace("/", "-")
- match = re.fullmatch(r"(\d{4})-(\d{1,2})", normalized_text)
- if match:
- year = int(match.group(1))
- month = int(match.group(2))
- normalized_values.append(f"{year:04d}-{month:02d}-01")
- else:
- normalized_values.append(text)
- parsed = pd.to_datetime(normalized_values, errors="coerce", format="%Y-%m-%d")
- if getattr(parsed, "notna", None) is not None and parsed.notna().all():
- return parsed
- return list(values)
- def wrap_text(value: object, width: int = 16) -> str:
- """Wrap long text labels for cleaner legends and axis ticks."""
- text = str(value)
- if len(text) <= width:
- return text
- return "\n".join(textwrap.wrap(text, width=width, break_long_words=False, break_on_hyphens=False))
- def beautify_axes(
- ax,
- *,
- title: str | None = None,
- xlabel: str | None = None,
- ylabel: str | None = None,
- rotate_xticks: int = 25,
- wrap_xticks: bool = False,
- wrap_width: int = 14,
- legend: bool = True,
- ):
- """Apply consistent axis-level polish to reduce overlap and improve readability."""
- if title:
- ax.set_title(title, pad=14)
- if xlabel:
- ax.set_xlabel(xlabel, labelpad=10)
- if ylabel:
- ax.set_ylabel(ylabel, labelpad=10)
- if wrap_xticks:
- tick_labels = [wrap_text(label.get_text(), width=wrap_width) for label in ax.get_xticklabels()]
- ax.set_xticklabels(tick_labels)
- for label in ax.get_xticklabels():
- label.set_rotation(rotate_xticks)
- label.set_horizontalalignment("right" if rotate_xticks else "center")
- ax.tick_params(axis="x", pad=6)
- ax.tick_params(axis="y", pad=6)
- ax.margins(x=0.02)
- if legend and ax.get_legend() is not None:
- ax.legend(loc="best", frameon=False)
- return ax
- def _resolve_save_figure_args(*args):
- """Support the new single-argument API and a minimal backward-compatible path."""
- plt, _ = configure_plotting_backend()
- if len(args) == 1:
- return plt.gcf(), args[0]
- if len(args) == 2 and hasattr(args[0], "savefig"):
- return args[0], args[1]
- raise TypeError("save_figure() expects save_figure(output_path) as the standard API.")
- def _is_layout_conflict(exc: Exception) -> bool:
- message = str(exc).lower()
- keywords = (
- "layout engine",
- "tight_layout",
- "constrained_layout",
- "colorbar layout",
- )
- return any(keyword in message for keyword in keywords)
- def _attempt_figure_save(fig, destination: Path) -> None:
- fig.savefig(destination, dpi=300, bbox_inches="tight", facecolor="white")
- def save_figure(*args) -> Path:
- """Save the current figure defensively.
- Standard API:
- save_figure(output_path)
- A minimal backward-compatible path for save_figure(fig, output_path) is kept
- internally, but prompt/tooling should only expose the single-argument form.
- """
- fig, output_path = _resolve_save_figure_args(*args)
- destination = Path(output_path)
- destination.parent.mkdir(parents=True, exist_ok=True)
- with warnings.catch_warnings():
- warnings.filterwarnings("ignore", message=".*figure layout has changed to tight.*")
- try:
- _attempt_figure_save(fig, destination)
- except Exception as exc:
- if not _is_layout_conflict(exc):
- raise
- # Defensive fallback: disable layout engines and retry without throwing
- # the common matplotlib heatmap/colorbar conflict back to the agent.
- try:
- if hasattr(fig, "set_layout_engine"):
- fig.set_layout_engine(None)
- except Exception:
- pass
- try:
- if hasattr(fig, "set_constrained_layout"):
- fig.set_constrained_layout(False)
- except Exception:
- pass
- try:
- fig.subplots_adjust(left=0.08, right=0.98, top=0.92, bottom=0.12)
- except Exception:
- pass
- _attempt_figure_save(fig, destination)
- return destination
|