Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4441,7 +4441,22 @@ export class ChartView {
d.style.boxSizing = "border-box";
}
this._applySlot(d, kind === "label" ? "axis_title" : "tick_label");
this._applyStyle(d, extraStyle);
const axisLabelStyle = kind === "label" ? {
"font-family": this._axisStyleValue(axis, "label_font_family"),
"font-style": this._axisStyleValue(axis, "label_font_style"),
"font-weight": this._axisStyleValue(axis, "label_font_weight"),
} : null;
if (axisLabelStyle) {
for (const key of Object.keys(axisLabelStyle)) {
if (axisLabelStyle[key] === undefined) delete axisLabelStyle[key];
}
}
this._applyStyle(
d,
axisLabelStyle || extraStyle
? { ...(axisLabelStyle || {}), ...(extraStyle || {}) }
: null,
);
this.labels.appendChild(d);
if (kind === "tick" && axis && axis.kind === "category" && yPlacement) {
// Rotation contributes half the untransformed label height to each
Expand Down
34 changes: 28 additions & 6 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,8 +935,6 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]:
_parse_color(_css(axis_style.get("tick_color"), default_axis)),
)

text_c = _parse_color(default_text)

def rotation_flag(angle: float) -> int:
normalized = angle % 360.0
if abs(normalized - 90.0) < 1e-9:
Expand Down Expand Up @@ -999,13 +997,22 @@ def emit_tick_labels(
_ticks, tick_labels, step = extra_y_ticks[axis_id]
emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False)
if spec.get("title"):
title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
title_italic, title_bold = _native_font_emphasis(
{
"font_style": title_style.get("font-style"),
"font_weight": title_style.get("font-weight", 600),
}
)
cmd.text(
width / 2,
plot["y"] - plot["top_axis_room"] - (10 if compact else 12),
1,
14,
text_c,
_px_size(title_style.get("font-size"), 14.0),
_parse_color(_css(title_style.get("color"), default_text)),
str(spec["title"]),
italic=title_italic,
bold=title_bold,
)

def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
Expand All @@ -1014,14 +1021,29 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
axis_style = axis.get("style") or {}
geometry = _axis_label_geometry(axis, plot, is_x=is_x)
anchor = {"start": 0, "middle": 1, "end": 2}[geometry["anchor"]]
cmd.text(
italic, bold = _native_font_emphasis(
{
"font_style": axis_style.get("label_font_style"),
"font_weight": axis_style.get("label_font_weight", 500),
}
)
args = (
geometry["x"],
geometry["y"],
anchor | rotation_flag(float(geometry["angle"])),
anchor,
geometry["font_size"],
_parse_color(_css(axis_style.get("label_color"), default_text)),
str(axis["label"]),
)
if italic or bold:
cmd.text(*args, angle=float(geometry["angle"]), italic=italic, bold=bold)
else:
cmd.text(
args[0],
args[1],
anchor | rotation_flag(float(geometry["angle"])),
*args[3:],
)

emit_axis_title(xa, is_x=True)
emit_axis_title(ya, is_x=False)
Expand Down
24 changes: 21 additions & 3 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1623,11 +1623,23 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
# -- chrome text ----------------------------------------------------------
chrome: list[str] = []
if spec.get("title"):
title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
title_size = _px_size(title_style.get("font-size"), 14.0)
title_weight = title_style.get("font-weight", 600)
title_family = title_style.get("font-family")
title_font_style = title_style.get("font-style")
title_font_attrs = (
f' font-family="{escape(str(title_family))}"' if title_family is not None else ""
) + (
f' font-style="{escape(str(title_font_style))}"' if title_font_style is not None else ""
)
chrome.append(
f'<text x="{_num(width / 2)}" '
f'y="{_num(plot["y"] - plot["top_axis_room"] - (10 if compact else 12))}" '
f'text-anchor="middle" font-size="14" font-weight="600" '
f'fill="{escape(default_text)}">{escape(str(spec["title"]))}</text>'
f'text-anchor="middle" font-size="{_num(title_size)}" '
f'font-weight="{escape(str(title_weight))}"{title_font_attrs} '
f'fill="{escape(_css(title_style.get("color"), default_text))}">'
f"{escape(str(spec['title']))}</text>"
)

def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
Expand All @@ -1638,9 +1650,15 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
x, y = float(geometry["x"]), float(geometry["y"])
angle = float(geometry["angle"])
transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else ""
family = axis_style.get("label_font_family")
font_style = axis_style.get("label_font_style")
font_attrs = (f' font-family="{escape(str(family))}"' if family is not None else "") + (
f' font-style="{escape(str(font_style))}"' if font_style is not None else ""
)
chrome.append(
f'<text x="{_num(x)}" y="{_num(y)}" text-anchor="{geometry["anchor"]}" '
f'font-size="{_num(float(geometry["font_size"]))}" font-weight="500" '
f'font-size="{_num(float(geometry["font_size"]))}" '
f'font-weight="{escape(str(axis_style.get("label_font_weight", 500)))}"{font_attrs} '
f'fill="{escape(_css(axis_style.get("label_color"), default_text))}"{transform}>'
f"{escape(str(axis['label']))}</text>"
)
Expand Down
146 changes: 92 additions & 54 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
)
from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba_array, scalar_float
from ._fmt import parse_fmt
from ._mathtext import mathtext_to_unicode
from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode
from ._plot_types import PlotTypeMixin
from ._rc import RcParams, rcParams
from ._ticker import AutoLocator, Locator, NullLocator, ScalarFormatter, as_formatter
Expand Down Expand Up @@ -526,6 +526,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None:
self._containers: list[Any] = []
self._axis: dict[str, dict[str, Any]] = {"x": {}, "y": {}, "y2": {}}
self._title: Optional[str] = None
self._title_style: dict[str, Any] = {}
self._legend = False
self._legend_options: dict[str, Any] = {}
self._extra_legends: list[Any] = []
Expand Down Expand Up @@ -814,6 +815,7 @@ def clear(self) -> None:
self._tickers = {}
self._hidden_spines = set()
self._title = None
self._title_style = {}
self._legend = False
self._legend_options = {}
self._extra_legends = []
Expand Down Expand Up @@ -2462,7 +2464,7 @@ def set_xlabel(self, label: str, **kwargs: Any) -> None:
"""
props = self._axis_props("x")
props["label"] = _plain_text(label)
_apply_axis_label_kwargs(props, kwargs, "set_xlabel()")
_apply_axis_label_kwargs(props, kwargs, "set_xlabel()", point_scale=self._point_scale())
self._invalidate()

def set_ylabel(self, label: str, **kwargs: Any) -> None:
Expand All @@ -2472,7 +2474,7 @@ def set_ylabel(self, label: str, **kwargs: Any) -> None:
"""
props = self._axis_props("y")
props["label"] = _plain_text(label)
_apply_axis_label_kwargs(props, kwargs, "set_ylabel()")
_apply_axis_label_kwargs(props, kwargs, "set_ylabel()", point_scale=self._point_scale())
self._invalidate()

def set_title(self, title: str, **kwargs: Any) -> None:
Expand All @@ -2482,8 +2484,11 @@ def set_title(self, title: str, **kwargs: Any) -> None:
are accepted as compatibility inputs; anything else raises loudly.
Basic mathtext (``$...$``) is rendered.
"""
_consume_text_kwargs(kwargs, "set_title()")
host = self._y2_of or self
host._title_style = _title_css_style(
_pop_text_style_kwargs(kwargs, "set_title()"),
point_scale=self._point_scale(),
)
host._title = _plain_text(title)
host._invalidate()

Expand Down Expand Up @@ -4705,13 +4710,19 @@ def _build_chart(self, width: int, height: int) -> Any:
tokens = dict(theme_tokens)
tokens["grid_color"] = self._grid_color if self._grid else "transparent"
children.append(xy.theme(style=self._theme_style, **tokens))
chrome_styles = self._chrome_styles
if self._title_style:
chrome_styles = {
**chrome_styles,
"title": {**chrome_styles.get("title", {}), **self._title_style},
}
self._chart = xy.chart(
*children,
title=self._title,
width=width,
height=height,
padding=chart_padding,
styles=self._chrome_styles,
styles=chrome_styles,
)
core_figure = self._chart.figure()
core_figure.frame_sides = [
Expand Down Expand Up @@ -5155,10 +5166,27 @@ def _validate_margin(value: Any, axis: str) -> float:
return margin


def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], context: str) -> None:
def _apply_axis_label_kwargs(
props: dict[str, Any],
kwargs: dict[str, Any],
context: str,
*,
point_scale: float,
) -> None:
labelpad = kwargs.pop("labelpad", None)
loc = kwargs.pop("loc", None)
_consume_text_kwargs(kwargs, context)
text_style = _pop_text_style_kwargs(kwargs, context)
axis_style = props.setdefault("style", {})
for source, target in (
("color", "label_color"),
("font_size", "label_size"),
("font_family", "label_font_family"),
("font_weight", "label_font_weight"),
("font_style", "label_font_style"),
):
if source in text_style:
value = text_style[source]
axis_style[target] = float(value) * point_scale if source == "font_size" else value
if labelpad is not None:
props["label_offset"] = float(labelpad)
if loc is not None:
Expand All @@ -5174,21 +5202,28 @@ def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], cont
props["label_position"] = positions[loc]


def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None:
# Accepted for Matplotlib-flavoured scripts. The native engine currently
# inherits font styling from the chart theme, so these kwargs are validated
# and retained as compatibility inputs rather than silently acting on data.
def _pop_text_style_kwargs(kwargs: dict[str, Any], context: str) -> dict[str, Any]:
"""Consume Matplotlib text kwargs and return renderer-backed font style."""
fontdict = kwargs.pop("fontdict", None)
if fontdict is not None:
if not isinstance(fontdict, Mapping):
raise TypeError(f"{context} fontdict must be a mapping or None")
kwargs = {**fontdict, **kwargs}

def pop_alias(primary: str, *aliases: str) -> Any:
value = kwargs.pop(primary, None)
for alias in aliases:
alias_value = kwargs.pop(alias, None)
if value is None:
value = alias_value
return value

size = pop_alias("fontsize", "size")
weight = pop_alias("fontweight", "weight")
family = pop_alias("fontfamily", "family")
font_style = pop_alias("fontstyle", "style")
color = kwargs.pop("color", None)
for key in (
"fontsize",
"size",
"fontdict",
"fontweight",
"weight",
"fontstyle",
"style",
"fontfamily",
"family",
"color",
"horizontalalignment",
"ha",
"verticalalignment",
Expand All @@ -5203,6 +5238,38 @@ def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None:
if kwargs:
raise TypeError(f"{context} got unsupported keyword argument {next(iter(kwargs))!r}")

style: dict[str, Any] = {}
if size is not None:
style["font_size"] = _font_size_points(size, rcParams["font.size"])
if weight is not None:
style["font_weight"] = str(weight)
if family is not None:
style["font_family"] = str(family)
if font_style is not None:
font_style = str(font_style)
if font_style not in {"normal", "italic", "oblique"}:
raise ValueError(f"{context} style must be 'normal', 'italic', or 'oblique'")
style["font_style"] = font_style
if color is not None:
style["color"] = resolve_color(color)
return style


def _title_css_style(style: dict[str, Any], *, point_scale: float) -> dict[str, Any]:
"""Translate renderer text style keys to the chart title's CSS slot."""
result: dict[str, Any] = {}
for source, target in (
("color", "color"),
("font_family", "font-family"),
("font_weight", "font-weight"),
("font_style", "font-style"),
):
if source in style:
result[target] = style[source]
if "font_size" in style:
result["font-size"] = f"{float(style['font_size']) * point_scale:g}px"
return result


def _tick_label_visibility(kwargs: dict[str, Any]) -> Optional[bool]:
values = []
Expand Down Expand Up @@ -5257,39 +5324,10 @@ def _plain_text(value: Any) -> str:

def _plain_text_with_math_italic_ranges(value: Any) -> tuple[str, list[tuple[int, int]]]:
"""Flatten simple mathtext while retaining its default italic spans."""
source = str(value)
parts: list[str] = []
ranges: list[tuple[int, int]] = []
cursor = 0
output_length = 0
while cursor < len(source):
start = source.find("$", cursor)
if start < 0:
tail = _plain_text(source[cursor:])
parts.append(tail)
break
prefix = _plain_text(source[cursor:start])
parts.append(prefix)
output_length += len(prefix)
end = source.find("$", start + 1)
if end < 0:
tail = _plain_text(source[start:])
parts.append(tail)
break
math = _plain_text(source[start : end + 1])
parts.append(math)
range_start: Optional[int] = None
for index, character in enumerate(math):
if character.isalpha() and range_start is None:
range_start = output_length + index
elif not character.isalpha() and range_start is not None:
ranges.append((range_start, output_length + index))
range_start = None
if range_start is not None:
ranges.append((range_start, output_length + len(math)))
output_length += len(math)
cursor = end + 1
return "".join(parts), ranges
converted, ranges = mathtext_italic_ranges(str(value))
if converted != str(value):
return converted, ranges
return _plain_text(value), []


def _masked_float(value: Any) -> np.ndarray:
Expand Down
Loading
Loading