"""Enhanced formatting utilities for dartwork-mpl.
This module provides additional formatting functions for axes,
tick labels, and other matplotlib elements.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from itertools import pairwise
from typing import Any, Literal
import matplotlib.ticker as ticker
from matplotlib.axes import Axes
__all__ = [
"avoid_tick_overlap",
"format_axis_billions",
"format_axis_currency",
"format_axis_millions",
"format_axis_myriad",
"format_axis_si",
"format_axis_year",
"recommend_tick_decimals",
"rotate_tick_labels",
"set_decimal",
]
_YEAR_SUFFIX = {"ko": "년", "ja": "年", "zh": "年", "en": ""}
def recommend_tick_decimals(values: Sequence[float]) -> int:
"""Recommend the minimum decimal places needed for numeric tick values.
The recommendation is based on the smallest positive step between
adjacent sorted values, with a conservative cap of four decimals.
Duplicate and non-finite values are ignored.
"""
finite: list[float] = []
for value in values:
try:
numeric = float(value)
except (TypeError, ValueError):
continue
if math.isfinite(numeric):
finite.append(numeric)
if len(finite) < 2:
return 0
ordered = sorted(finite)
diffs = [
right - left for left, right in pairwise(ordered) if right - left > 0
]
if not diffs:
return 0
step = min(diffs)
return min(4, max(0, math.ceil(-math.log10(step) - 1e-9)))
def format_axis_year(
ax: Axes,
axis: Literal["x", "y", "both"] = "x",
locale: Literal["ko", "ja", "zh", "en"] = "ko",
digits: Literal[2, 4] = 2,
) -> None:
"""Format numeric year ticks with a locale suffix.
Tick years use the last two digits by default (``2025`` -> ``25년``):
four-digit years double the tick width and crowd neighbouring labels.
Pass ``digits=4`` only when an axis spans a century or more, where two
digits cannot tell ``1965`` from ``2065``.
Parameters
----------
ax:
Matplotlib axes to format.
axis:
Axis to format: ``"x"``, ``"y"``, or ``"both"``.
locale:
Locale suffix to apply. Korean uses ``년``, Japanese and Chinese use
``年``, and English uses no suffix.
digits:
``2`` (default) prints ``25``; ``4`` prints ``2025``.
"""
if locale not in _YEAR_SUFFIX:
valid = ", ".join(_YEAR_SUFFIX)
raise ValueError(f"Unknown locale {locale!r}; valid locales: {valid}")
if digits not in (2, 4):
raise ValueError(f"digits must be 2 or 4, got {digits!r}")
suffix = _YEAR_SUFFIX[locale]
def formatter(value: float, _pos: int | None = None) -> str:
year = round(value)
if digits == 2:
return f"{year % 100:02d}{suffix}"
return f"{year}{suffix}"
year_formatter = ticker.FuncFormatter(formatter)
if axis in ("x", "both"):
ax.xaxis.set_major_formatter(year_formatter)
if axis in ("y", "both"):
ax.yaxis.set_major_formatter(year_formatter)
_MYRIAD_UNIT_LADDERS: dict[str, tuple[tuple[int, str], ...]] = {
"ko": ((10**4, "만"), (10**8, "억"), (10**12, "조"), (10**16, "경")),
"zh": ((10**4, "万"), (10**8, "亿"), (10**12, "兆"), (10**16, "京")),
"ja": ((10**4, "万"), (10**8, "億"), (10**12, "兆"), (10**16, "京")),
}
def format_axis_myriad(
ax: Axes,
axis: str = "y",
*,
locale: str = "ko",
decimals: int = 1,
currency: str = "",
) -> None:
"""Format axis ticks using East-Asian myriad units.
Parameters
----------
ax:
Matplotlib axes to format.
axis:
Which axis to format: "x", "y", or "both".
locale:
Unit ladder locale: "ko", "zh", or "ja".
decimals:
Number of decimal places before trailing zero trimming.
currency:
Optional currency prefix inserted after the sign.
"""
if locale not in _MYRIAD_UNIT_LADDERS:
raise ValueError(
f"locale must be one of 'ko', 'zh', or 'ja' (got {locale!r})"
)
if axis not in {"x", "y", "both"}:
raise ValueError("axis must be one of 'x', 'y', or 'both'")
if decimals < 0:
raise ValueError("decimals must be non-negative")
unit_ladder = _MYRIAD_UNIT_LADDERS[locale]
def _format_myriad(value: float, _pos: int | None = None) -> str:
if value == 0:
return "0"
sign = "-" if value < 0 else ""
prefix = f"{sign}{currency}"
abs_value = abs(value)
if abs_value < 10_000:
return f"{prefix}{abs_value:,.0f}"
threshold, unit = max(
item for item in unit_ladder if item[0] <= abs_value
)
scaled = abs_value / threshold
formatted = f"{scaled:,.{decimals}f}"
if decimals > 0:
formatted = formatted.rstrip("0").rstrip(".")
return f"{prefix}{formatted}{unit}"
formatter = ticker.FuncFormatter(_format_myriad)
if axis in {"x", "both"}:
ax.xaxis.set_major_formatter(formatter)
if axis in {"y", "both"}:
ax.yaxis.set_major_formatter(formatter)
[docs]
def rotate_tick_labels(
ax: Axes,
axis: Literal["x", "y", "both"] = "x",
rotation: float = 45,
ha: Literal["left", "center", "right"] | None = None,
) -> None:
"""Rotate tick labels for better readability.
Parameters
----------
ax : Axes
Matplotlib axes
axis : Literal["x", "y", "both"]
Which axis to rotate
rotation : float
Rotation angle in degrees
ha : Literal["left", "center", "right"] | None
Horizontal alignment. If None, automatically set based on rotation
Examples
--------
>>> rotate_tick_labels(ax) # Rotate x-axis labels 45 degrees
>>> rotate_tick_labels(ax, rotation=90, axis="both")
"""
resolved_ha: Literal["left", "center", "right"]
if ha is None:
# Auto-determine alignment based on rotation
if rotation > 0:
resolved_ha = "right"
elif rotation < 0:
resolved_ha = "left"
else:
resolved_ha = "center"
else:
resolved_ha = ha
# Apply rotation and alignment per-label rather than calling
# set_xticklabels(get_xticklabels(), ...) — that pattern emits
# matplotlib's "set_ticklabels() should only be used with a
# fixed number of ticks" warning since the locator may not be
# a FixedLocator. Iterating preserves the existing locator and
# mutates the existing Text artists in place.
if axis in ("x", "both"):
for label in ax.get_xticklabels():
label.set_rotation(rotation)
label.set_horizontalalignment(resolved_ha)
if axis in ("y", "both"):
for label in ax.get_yticklabels():
label.set_rotation(rotation)
label.set_horizontalalignment(resolved_ha)
def avoid_tick_overlap(
ax: Axes,
axis: Literal["x", "y"] = "x",
*,
max_visible: int = 8,
rotation: float = 30,
) -> None:
"""Reduce dense tick-label collisions before layout is computed.
If an axis has too many visible labels, hide evenly spaced intermediate
labels while keeping the first and last visible. If the remaining labels
still overlap after a draw, rotate them in place. Call this after
setting ticks/labels and before ``simple_layout``.
"""
fig = ax.get_figure()
if fig is None or fig.canvas is None:
return
labels = ax.get_xticklabels() if axis == "x" else ax.get_yticklabels()
visible = [
label for label in labels if label.get_visible() and label.get_text()
]
if len(visible) <= 1:
return
if max_visible > 0 and len(visible) > max_visible:
step = max(1, math.ceil(len(visible) / max_visible))
last_idx = len(visible) - 1
for idx, label in enumerate(visible):
label.set_visible(idx == 0 or idx == last_idx or idx % step == 0)
canvas: Any = fig.canvas
canvas.draw()
renderer = canvas.get_renderer()
visible = [
label for label in labels if label.get_visible() and label.get_text()
]
if len(visible) <= 1:
return
boxes = [label.get_window_extent(renderer) for label in visible]
if axis == "x":
boxes = sorted(boxes, key=lambda box: box.x0)
overlaps = any(a.x1 > b.x0 - 1 for a, b in pairwise(boxes))
if overlaps:
for label in visible:
label.set_rotation(rotation)
label.set_horizontalalignment("right")
else:
boxes = sorted(boxes, key=lambda box: box.y0)
overlaps = any(a.y1 > b.y0 - 1 for a, b in pairwise(boxes))
if overlaps:
for label in visible:
label.set_rotation(rotation)
label.set_verticalalignment("center")
[docs]
def set_decimal(ax: Axes, xn: int | None = None, yn: int | None = None) -> None:
"""Format current and future ticks with Python fixed-point precision.
Keep each axis's locator and view limits, including after data changes.
Half-even rounding and negative zero follow Python float formatting.
Invalid precision raises immediately before installing that axis's formatter;
an axis requested as None is untouched. Return None.
Parameters
----------
ax : matplotlib.axes.Axes
The Axes to modify.
xn : int | None, optional
Number of decimal places for x-axis tick labels.
If None, the x-axis is left unchanged.
yn : int | None, optional
Number of decimal places for y-axis tick labels.
If None, the y-axis is left unchanged.
"""
if xn is not None:
format(0.0, f".{xn}f") # Validate before changing this axis.
ax.xaxis.set_major_formatter(
ticker.FuncFormatter(lambda value, _position: f"{value:.{xn}f}")
)
if yn is not None:
format(0.0, f".{yn}f")
ax.yaxis.set_major_formatter(
ticker.FuncFormatter(lambda value, _position: f"{value:.{yn}f}")
)