Skip to content
Open
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
19 changes: 11 additions & 8 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,10 +963,10 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
# clip the bounded/truncated equivalent to the plot rectangle.
cmd.clip(px0, py0, plot["w"], plot["h"])
if show_main_legend:
_emit_legend(cmd, named, plot, spec.get("legend") or {})
_emit_legend(cmd, named, plot, spec.get("legend") or {}, default_text)
for extra, items in extra_legends:
if items:
_emit_legend(cmd, items, plot, extra)
_emit_legend(cmd, items, plot, extra, default_text)
if legend_present:
cmd.clip(0, 0, width, height)
if spec.get("colorbar"):
Expand All @@ -975,6 +975,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
spec["colorbar"],
plot,
_colorbar_right_axis_room(ya, extra_y_axes, compact),
default_text,
)

w_px, h_px = max(1, round(width * scale)), max(1, round(height * scale))
Expand Down Expand Up @@ -1659,6 +1660,7 @@ def _emit_legend(
named: list[dict[str, Any]],
plot: dict[str, float],
options: dict[str, Any],
text_color: str = _TEXT,
) -> None:
legend = _legend_layout(named, plot, options)
if not legend["visible_count"]:
Expand All @@ -1684,7 +1686,7 @@ def _emit_legend(
)
cmd.fill(_rect_pts(x, y, x + box_w, y + box_h), frame)
if title:
cmd.text(x + pad, y + pad / 2 + 11, 0, 11, _parse_color(_TEXT), str(title))
cmd.text(x + pad, y + pad / 2 + 11, 0, 11, _parse_color(text_color), str(title))
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
color_str = _css(
Expand All @@ -1710,14 +1712,15 @@ def _emit_legend(
)
else:
cmd.fill(_rect_pts(hx0, cy - 4, hx1, cy + 4), c)
cmd.text(hx1 + gap, ry + 11, 0, 11, _parse_color(_TEXT), legend["names"][i])
cmd.text(hx1 + gap, ry + 11, 0, 11, _parse_color(text_color), legend["names"][i])


def _emit_colorbar(
cmd: _Cmd,
options: dict[str, Any],
plot: dict[str, float],
right_axis_room: float = 0.0,
text_color: str = _TEXT,
) -> None:
from ._svg import _linear_ticks, _lut

Expand Down Expand Up @@ -1782,7 +1785,7 @@ def _emit_colorbar(
y + height + 13,
1,
10,
_parse_color(_TEXT),
_parse_color(text_color),
f"{value:g}",
)
if options.get("label"):
Expand All @@ -1791,7 +1794,7 @@ def _emit_colorbar(
y + height + 26,
1,
10,
_parse_color(_TEXT),
_parse_color(text_color),
str(options["label"]),
)
else:
Expand All @@ -1806,7 +1809,7 @@ def _emit_colorbar(
y + height * (1 - (value - lo) / span) + 4,
0,
10,
_parse_color(_TEXT),
_parse_color(text_color),
f"{value:g}",
)
# The native text primitive does not rotate; a compact label above the
Expand All @@ -1817,7 +1820,7 @@ def _emit_colorbar(
y - 5,
0,
10,
_parse_color(_TEXT),
_parse_color(text_color),
str(options["label"]),
)

Expand Down
25 changes: 15 additions & 10 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1523,17 +1523,18 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
append_axis_title(axis, is_x=False)
named = [t for t in spec["traces"] if t.get("name")]
if spec.get("show_legend", True) and named:
chrome.append(_legend(named, plot, spec.get("legend") or {}, clip_id))
chrome.append(_legend(named, plot, spec.get("legend") or {}, clip_id, default_text))
for extra in spec.get("extra_legends") or []:
items = extra.get("items") or []
if items:
chrome.append(_legend(items, plot, extra, clip_id))
chrome.append(_legend(items, plot, extra, clip_id, default_text))
if spec.get("colorbar"):
chrome.append(
_colorbar(
spec["colorbar"],
plot,
_colorbar_right_axis_room(ya, extra_y_axes, compact),
default_text,
)
)

Expand Down Expand Up @@ -2301,7 +2302,9 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An
}


def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
def _legend(
named: list[dict], plot: dict, options: dict, clip_id: str, text_color: str = _TEXT
) -> str:
legend = _legend_layout(named, plot, options)
if not legend["visible_count"]:
# A plot too short for even one entry: no floating frame/title either.
Expand Down Expand Up @@ -2335,7 +2338,7 @@ def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
if title:
rows.append(
f'<text x="{_num(x + pad)}" y="{_num(y + pad / 2 + 11)}" '
f'font-weight="600" fill="{_TEXT}">{escape(str(title))}</text>'
f'font-weight="600" fill="{escape(text_color)}">{escape(str(title))}</text>'
)
for i, t in enumerate(named[: legend["visible_count"]]):
style = t.get("style") or {}
Expand Down Expand Up @@ -2381,12 +2384,14 @@ def _legend(named: list[dict], plot: dict, options: dict, clip_id: str) -> str:
)
rows.append(
f'<text x="{_num(hx1 + gap)}" y="{_num(ry + 11)}" '
f'fill="{_TEXT}">{escape(legend["names"][i])}</text>'
f'fill="{escape(text_color)}">{escape(legend["names"][i])}</text>'
)
return f'<g clip-path="url(#{clip_id})">{"".join(rows)}</g>'


def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
def _colorbar(
options: dict, plot: dict, right_axis_room: float = 0.0, text_color: str = _TEXT
) -> str:
cmap = str(options.get("colormap", "viridis"))
gradient_id = f"xy-colorbar-{sum(map(ord, cmap))}"
stops = _colormap_stops(cmap)
Expand All @@ -2412,11 +2417,11 @@ def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
label_node = (
f'<text x="{_num(x + width + 38)}" y="{_num(y + height / 2)}" '
f'text-anchor="middle" transform="rotate(-90 {_num(x + width + 38)} '
f'{_num(y + height / 2)})" fill="{_TEXT}">{escape(label)}</text>'
f'{_num(y + height / 2)})" fill="{escape(text_color)}">{escape(label)}</text>'
if label and orientation != "horizontal"
else (
f'<text x="{_num(x + width / 2)}" y="{_num(y + height + 22)}" '
f'text-anchor="middle" fill="{_TEXT}">{escape(label)}</text>'
f'text-anchor="middle" fill="{escape(text_color)}">{escape(label)}</text>'
if label
else ""
)
Expand All @@ -2433,14 +2438,14 @@ def _colorbar(options: dict, plot: dict, right_axis_room: float = 0.0) -> str:
"".join(
f'<text x="{_num(x + width + 4)}" '
f'y="{_num(y + height * (1 - (value - lo) / span) + 4)}" '
f'fill="{_TEXT}">{value:g}</text>'
f'fill="{escape(text_color)}">{value:g}</text>'
for value in tick_positions
)
if orientation != "horizontal"
else "".join(
f'<text x="{_num(x + width * (value - lo) / span)}" '
f'y="{_num(y + height + 12)}" text-anchor="middle" '
f'fill="{_TEXT}">{value:g}</text>'
f'fill="{escape(text_color)}">{value:g}</text>'
for value in tick_positions
)
)
Expand Down
22 changes: 22 additions & 0 deletions tests/test_png_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,28 @@ def test_png_is_screen_bounded_for_large_lines() -> None:
assert spec["traces"][0]["n_points"] == n # source size still recorded (§28)


def test_raster_legend_text_honors_theme_text_color() -> None:
# Red is reserved for the theme text color; every other paint is green,
# and the tick labels are overridden, so red pixels can only come from
# the legend text.
chart = xy.line_chart(
xy.line(x=[0.0, 1.0], y=[0.0, 1.0], color="#00ff00", name="walk"),
xy.x_axis(style={"tick_label_color": "#00ff00"}),
xy.y_axis(style={"tick_label_color": "#00ff00"}),
xy.legend(loc="upper right"),
xy.theme(
text_color="#ff0000",
grid_color="transparent",
axis_color="#00ff00",
),
width=300,
height=200,
)
img = _raster.render_raster(*chart.figure().build_payload(), scale=1)
red = (img[:, :, 0] > 150) & (img[:, :, 1] < 80) & (img[:, :, 2] < 80)
assert int(red.sum()) > 20


def test_render_is_non_blank_and_has_background() -> None:
fig = Figure(width=200, height=120).line([0.0, 1.0], [0.0, 1.0])
img = _raster.render_raster(*fig.build_payload(), scale=1)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_svg_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,30 @@ def test_every_chart_kind_exports_wellformed_svg() -> None:
assert 'xmlns="http://www.w3.org/2000/svg"' in svg


def test_svg_legend_text_honors_theme_text_color() -> None:
chart = xy.line_chart(
xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"),
xy.legend(loc="upper right", title="models"),
xy.theme(text_color="#ffffff"),
width=300,
height=200,
)
svg = chart.figure().to_svg()
assert re.search(r'<text[^>]*fill="#ffffff"[^>]*>walk</text>', svg)
assert re.search(r'<text[^>]*fill="#ffffff"[^>]*>models</text>', svg)

# Without a theme the legend keeps the light-mode default text color.
plain = xy.line_chart(
xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"),
xy.legend(loc="upper right"),
width=300,
height=200,
)
assert re.search(
r'<text[^>]*fill="rgba\(32,32,32,0\.85\)"[^>]*>walk</text>', plain.figure().to_svg()
)


def test_svg_stays_screen_bounded_for_large_lines() -> None:
n = 2_000_000
y = np.cumsum(np.random.default_rng(1).normal(size=n))
Expand Down
Loading