Skip to content
Draft
40 changes: 28 additions & 12 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,7 +1497,8 @@ export class ChartView {
this.overlay.width = this.size.w * this.dpr;
this.overlay.height = this.size.h * this.dpr;
for (const lg of this._legends || []) {
this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right");
const anchor = lg.dataset.xyLegendAnchor ? JSON.parse(lg.dataset.xyLegendAnchor) : null;
this._positionLegend(lg, lg.dataset.xyLegendLoc || "upper right", anchor);
}
this._positionReductionBadges();
this._positionColorbar();
Expand Down Expand Up @@ -1762,10 +1763,14 @@ export class ChartView {
const horizontal = ncols > 1;
lg.style.cssText = "position:absolute;" +
`display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` +
"overflow:auto;";
"column-gap:22px;row-gap:2px;overflow:auto;";
lg.dataset.xyLegendLoc = loc;
this._positionLegend(lg, loc);
if (Array.isArray(options.anchor)) {
lg.dataset.xyLegendAnchor = JSON.stringify(options.anchor);
}
this._positionLegend(lg, loc, options.anchor);
this._applySlot(lg, "legend");
this._applyStyle(lg, options.style);
if (options.title) {
const title = document.createElement("div");
title.textContent = String(options.title);
Expand Down Expand Up @@ -1850,24 +1855,34 @@ export class ChartView {
return lg;
}

_positionLegend(lg, loc) {
_positionLegend(lg, loc, anchor = null) {
if (!lg) return;
// Responsive anchors flow through private custom properties consumed by a
// zero-specificity rule. Author classes or component styles can still set
// real left/right/top/bottom/transform declarations and win normally.
const rightInset = this.size.w - (this.plot.x + this.plot.w);
const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center";
const v = loc.includes("upper") ? "upper" : loc.includes("lower") ? "lower" : "center";
const left = h === "left" ? this.plot.x + 6 : h === "center" ? this.plot.x + this.plot.w / 2 : null;
const right = h === "right" ? rightInset + 6 : null;
const top = v === "upper" ? this.plot.y + 6 : v === "center" ? this.plot.y + this.plot.h / 2 : null;
const bottom = v === "lower" ? this.size.h - (this.plot.y + this.plot.h) + 6 : null;
let left = h === "left" ? this.plot.x + 6 : h === "center" ? this.plot.x + this.plot.w / 2 : null;
let right = h === "right" ? rightInset + 6 : null;
let top = v === "upper" ? this.plot.y + 6 : v === "center" ? this.plot.y + this.plot.h / 2 : null;
let bottom = v === "lower" ? this.size.h - (this.plot.y + this.plot.h) + 6 : null;
if (Array.isArray(anchor) && (anchor.length === 2 || anchor.length === 4)) {
const hx = h === "left" ? 0 : h === "right" ? 1 : 0.5;
const vy = v === "lower" ? 0 : v === "upper" ? 1 : 0.5;
const aw = anchor.length === 4 ? Number(anchor[2]) : 0;
const ah = anchor.length === 4 ? Number(anchor[3]) : 0;
left = this.plot.x + (Number(anchor[0]) + hx * aw) * this.plot.w;
top = this.plot.y + (1 - Number(anchor[1]) - vy * ah) * this.plot.h;
right = null;
bottom = null;
}
lg.style.setProperty("--xy-legend-left", left == null ? "auto" : left + "px");
lg.style.setProperty("--xy-legend-right", right == null ? "auto" : right + "px");
lg.style.setProperty("--xy-legend-top", top == null ? "auto" : top + "px");
lg.style.setProperty("--xy-legend-bottom", bottom == null ? "auto" : bottom + "px");
const tx = h === "center" ? "-50%" : "0";
const ty = v === "center" ? "-50%" : "0";
const tx = h === "center" ? "-50%" : h === "right" && anchor ? "-100%" : "0";
const ty = v === "center" ? "-50%" : v === "lower" && anchor ? "-100%" : "0";
lg.style.setProperty("--xy-legend-transform", `translate(${tx},${ty})`);
}

Expand Down Expand Up @@ -4316,12 +4331,13 @@ export class ChartView {
const frameSides = Array.isArray(s.frame_sides)
? s.frame_sides
: [xAxis.side || "bottom", yAxis.side || "left"];
if (!hideY) {
const explicitFrameSides = Array.isArray(s.frame_sides);
if (!hideY || explicitFrameSides) {
const yWidth = Math.max(1, this._axisStyleNumber(yAxis, "axis_width", 1));
if (frameSides.includes("left")) rule(yAxis, p.x, p.y, yWidth, p.h);
if (frameSides.includes("right")) rule(yAxis, p.x + p.w - yWidth, p.y, yWidth, p.h);
}
if (!hideX) {
if (!hideX || explicitFrameSides) {
const xHeight = Math.max(1, this._axisStyleNumber(xAxis, "axis_width", 1));
if (frameSides.includes("top")) rule(xAxis, p.x, p.y, p.w, xHeight);
if (frameSides.includes("bottom")) rule(xAxis, p.x, p.y + p.h - xHeight, p.w, xHeight);
Expand Down
42 changes: 42 additions & 0 deletions python/xy/_fontmetrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# @generated by scripts/gen_font.py — do not edit by hand.
"""Horizontal advances of the bundled DejaVu Sans face, at `BASE_PX`.

The native rasterizer sets text from a baked coverage atlas (`src/font.rs`);
these are that atlas's matching advances, so Python-side static layout can
measure a string the same way `src/raster.rs` will draw it. Advances are
pixels at `BASE_PX` and scale linearly with the requested font size.
"""

BASE_PX = 16
FIRST = 32
LAST = 126

# fmt: off
#: Advance per codepoint for ASCII `FIRST`..`LAST`, in codepoint order.
ASCII_ADVANCES = (
5, 6, 7, 13, 10, 15, 12, 4, 6, 6, 8, 13, 5, 6, 5, 5,
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 5, 5, 13, 13, 13, 8,
16, 11, 11, 11, 12, 10, 9, 12, 12, 5, 5, 10, 9, 14, 12, 13,
10, 13, 11, 10, 10, 12, 11, 16, 11, 10, 11, 6, 5, 6, 13, 8,
8, 10, 10, 9, 10, 10, 6, 10, 10, 4, 4, 9, 4, 16, 10, 10,
10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13,
)

#: Advance per codepoint for the non-ASCII glyphs the atlas carries.
EXTRA_ADVANCES = {
176: 8, 177: 13, 178: 6, 179: 6, 181: 10, 183: 5, 185: 6, 215: 13,
915: 9, 916: 11, 920: 13, 923: 11, 926: 10, 928: 12, 931: 10, 933: 10,
934: 13, 936: 13, 937: 12, 945: 11, 946: 10, 947: 9, 948: 10, 949: 9,
950: 9, 951: 10, 952: 10, 953: 5, 954: 9, 955: 9, 956: 10, 957: 9,
958: 9, 959: 10, 960: 10, 961: 10, 963: 10, 964: 10, 965: 9, 966: 11,
967: 9, 968: 11, 969: 13, 7522: 3, 7523: 4, 7524: 6, 7525: 7, 8211: 8,
8212: 16, 8216: 5, 8217: 5, 8220: 8, 8221: 8, 8230: 16, 8304: 6, 8305: 3,
8308: 6, 8309: 6, 8310: 6, 8311: 6, 8312: 6, 8313: 6, 8314: 8, 8315: 8,
8316: 8, 8317: 4, 8318: 4, 8319: 6, 8320: 6, 8321: 6, 8322: 6, 8323: 6,
8324: 6, 8325: 6, 8326: 6, 8327: 6, 8328: 6, 8329: 6, 8330: 8, 8331: 8,
8332: 8, 8333: 4, 8334: 4, 8336: 6, 8337: 7, 8338: 7, 8339: 7, 8341: 6,
8342: 7, 8343: 3, 8344: 10, 8345: 6, 8346: 7, 8347: 6, 8348: 5, 8592: 13,
8594: 13, 8706: 8, 8711: 11, 8712: 14, 8722: 13, 8723: 13, 8730: 10, 8733: 11,
8734: 13, 8747: 8, 8776: 13, 8800: 13, 8804: 13, 8805: 13,
}
# fmt: on
65 changes: 49 additions & 16 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,9 +776,10 @@ def render_raster(
# "none" silences the whole axis chrome (sparklines); "off" hides only the
# label text and keeps baselines and the axis title (mpl shared axes).
frame_sides = spec.get("frame_sides")
explicit_frame_sides = frame_sides is not None
if frame_sides is None:
frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")]
if not hide_y:
if not hide_y or explicit_frame_sides:
if "left" in frame_sides:
cmd.stroke(
[(px0, py0), (px0, py1)],
Expand All @@ -791,7 +792,7 @@ def render_raster(
float(ystyle.get("axis_width", 1)),
_parse_color(_css(ystyle.get("axis_color"), default_axis)),
)
if not hide_x:
if not hide_x or explicit_frame_sides:
if "top" in frame_sides:
cmd.stroke(
[(px0, py0), (px1, py0)],
Expand Down Expand Up @@ -1003,17 +1004,28 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
named = [t for t in spec["traces"] if t.get("name")]
show_main_legend = spec.get("show_legend", True) and bool(named)
extra_legends = [(extra, extra.get("items") or []) for extra in spec.get("extra_legends") or []]
legend_present = show_main_legend or any(items for _extra, items in extra_legends)
if legend_present:
# The browser scrolls an oversized legend. Static files cannot, so
# clip the bounded/truncated equivalent to the plot rectangle.
cmd.clip(px0, py0, plot["w"], plot["h"])
legends: list[tuple[list[dict[str, Any]], dict[str, Any]]] = []
if show_main_legend:
_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, default_text)
if legend_present:
legends.append((named, spec.get("legend") or {}))
legends.extend((items, extra) for extra, items in extra_legends if items)
# The browser scrolls an oversized legend. Static files cannot, so clip the
# bounded/truncated equivalent to the plot rectangle. The exemption for an
# anchored legend (which is placed relative to, and may sit outside, the
# axes) is scoped per legend exactly as in `_svg.py`: one anchored legend
# must not lift the clip off its non-anchored siblings. The clip is a
# stateful raster command, so only emit it on a transition — an
# all-anchored or all-unanchored figure yields the same stream as before.
clipped_to_plot = False
for items, options in legends:
want_clip = not options.get("anchor")
if want_clip != clipped_to_plot:
if want_clip:
cmd.clip(px0, py0, plot["w"], plot["h"])
else:
cmd.clip(0, 0, width, height)
clipped_to_plot = want_clip
_emit_legend(cmd, items, plot, options, default_text)
if clipped_to_plot:
cmd.clip(0, 0, width, height)
if spec.get("colorbar"):
_emit_colorbar(
Expand Down Expand Up @@ -1300,6 +1312,14 @@ def read(index: int) -> np.ndarray:

fill_op = _fill_opacity(style, 0.8)
stroke_op = _stroke_opacity(style, 0.8)
artist_alpha = style.get("artist_alpha")
if artist_alpha is not None:
# Matplotlib artist alpha replaces the intrinsic paint alpha. Scalar
# alpha does not need a style channel, so retain it in both affine
# scatter fast paths instead of silently painting opaque points.
scalar_alpha = float(artist_alpha)
fill_op *= scalar_alpha
stroke_op *= scalar_alpha
sw = float(style.get("stroke_width", 0.0))
sym = _SYMBOLS.get(style.get("symbol", "circle"), 0)
# Transparent is the private wire sentinel for edgecolors="face". The
Expand Down Expand Up @@ -1958,7 +1978,7 @@ def _emit_legend(
pad, handle, gap = legend["pad"], legend["handle"], legend["gap"]
line_h, ncols = legend["line_h"], legend["ncols"]
title, title_h = legend["title"], legend["title_h"]
cell_w = legend["cell_w"]
column_offsets = legend["column_offsets"]
box_w, box_h = legend["box_w"], legend["box_h"]
x, y = legend["x"], legend["y"]
# frameon=False (background transparent) drops the box entirely (§ mpl parity).
Expand All @@ -1973,6 +1993,11 @@ def _emit_legend(
else (128, 128, 128, round(255 * alpha))
)
cmd.fill(_rect_pts(x, y, x + box_w, y + box_h), frame)
border = _rgba(style_opts.get("borderColor"), "#cccccc", alpha)
# closed=True: _rect_pts is four corners, so an open polyline strokes
# top/right/bottom and silently drops the left edge. Matplotlib's frame
# is a FancyBboxPatch — all four sides — and so is SVG's <rect>.
cmd.stroke(_rect_pts(x, y, x + box_w, y + box_h), 1.0, border, closed=True)
if 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"]]):
Expand All @@ -1983,14 +2008,22 @@ def _emit_legend(
)
c = _parse_color(color_str)
col, row = i % ncols, i // ncols
rx, ry = x + col * cell_w, y + pad / 2 + title_h + row * line_h
hx0, hx1, cy = rx + pad, rx + pad + handle, ry + 7
rx, ry = x + column_offsets[col], y + pad / 2 + title_h + row * line_h
hx0, hx1, cy = rx, rx + handle, ry + 7
kind = t.get("kind")
if kind == "scatter":
sym = _SYMBOLS.get(style.get("symbol", "circle"), 0)
sw = float(style.get("stroke_width", 0.0))
stroke = _rgba(style.get("stroke"), color_str) if sw > 0 else (0, 0, 0, 0)
cmd.point((hx0 + hx1) / 2, cy, 4.0, sym, c, sw, stroke)
cmd.point(
(hx0 + hx1) / 2,
cy,
max(0.5, float(style.get("size", 8.0)) / 2.0),
sym,
c,
sw,
stroke,
)
elif kind in _LEGEND_LINE_KINDS:
cmd.stroke(
[(hx0, cy), (hx1, cy)],
Expand Down
Loading