diff --git a/docs/components/axes.md b/docs/components/axes.md index ed1b0b8b..a6317382 100644 --- a/docs/components/axes.md +++ b/docs/components/axes.md @@ -212,7 +212,13 @@ xy.x_axis(line=False, ticks=False) # tick labels, no baseline or ticks The switches compile to ordinary axis `style` properties, so they work in HTML, SVG, and native PNG alike, and an explicit `style=` property still wins over a -switch. `text` controls whether axis text is *drawn*; `tick_labels=` supplies +switch. An axis with nothing left to draw also reserves nothing: its tick-label +gutter collapses, on either side, so `show=False` on both axes plus `padding=0` +gives a plot that reaches the container edge — the sparkline case. Authored +`padding` is still reserved, and with no `padding` at all the label-aware +default margins still apply; what a switched-off axis no longer adds is a +gutter of its own. A grid survives that, since it +is drawn inside the plot and never needed a gutter. `text` controls whether axis text is *drawn*; `tick_labels=` supplies the label strings. ## Style Axes diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 2c6df776..9451f71a 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -788,8 +788,9 @@ export class ChartView { const baseBottom = pad ? pad[2] : compact ? 36 : MARGIN.b; const bottomAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && - (this._axisTickLabelSides(axis).includes("bottom") || axis.side !== "top") && - this._axisTickLabelStrategy(axis) !== "none"); + (this._axisTickLabelSides(axis).includes("bottom") || axis.side !== "top" + || this._axisOutwardTickRoom(axis, "bottom") > 0) && + this._axisGutterVisible(axis, "bottom")); const hasBottomAxis = bottomAxes.length > 0; // A named x axis can own the top edge even when the primary x axis stays // on the bottom. Reserve one shared gutter for every top-side x axis; @@ -797,8 +798,9 @@ export class ChartView { // become part of the public API (the same rule used by secondary y axes). const topAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("x") && - (this._axisTickLabelSides(axis).includes("top") || axis.side === "top") && - this._axisTickLabelStrategy(axis) !== "none"); + (this._axisTickLabelSides(axis).includes("top") || axis.side === "top" + || this._axisOutwardTickRoom(axis, "top") > 0) && + this._axisGutterVisible(axis, "top")); const hasTopAxis = topAxes.length > 0; const authoredLeft = pad ? (responsivePad ? Math.min(pad[3], 46) : pad[3]) @@ -842,8 +844,9 @@ export class ChartView { const measuredLeft = Math.max(authoredLeft, this._yAxisLeftRoom(plotHeight)); const rightAxes = Object.values(this.axes || {}).filter((axis: any) => axis && String(axis.id || "").startsWith("y") && - (this._axisTickLabelSides(axis).includes("right") || axis.side === "right") && - this._axisTickLabelStrategy(axis) !== "none"); + (this._axisTickLabelSides(axis).includes("right") || axis.side === "right" + || this._axisOutwardTickRoom(axis, "right") > 0) && + this._axisGutterVisible(axis, "right")); // The vertical colorbar shifts right by this room (see _positionColorbar); // the Python SVG/raster exporters apply the identical 42/54 rule. this._rightAxisRoom = rightAxes.length ? (compact ? 42 : 54) : 0; @@ -946,7 +949,9 @@ export class ChartView { // `_layout` clears `_legendRect` just before this call, legend sizing and // positioning then fell back to `this.plot` and drew the legend on top of // the marks. Track it and skip only the inset. - const labelsHidden = this._axisTickLabelStrategy(xAxisSpec) === "none"; + // The same question every cartesian gutter asks: `"off"` draws no angular + // label any more than `"none"` does, and neither does a transparent paint. + const labelsHidden = !this._axisTickLabelsVisible(xAxisSpec); // A legend gutter comes off the canvas edge FIRST, before the disc is fitted // to what remains, so the disc never occupies the gutter and the legend // never occupies the disc. Mirrors the same block in `_recut_polar_plot`. @@ -995,7 +1000,7 @@ export class ChartView { // natural home — and it is placed outward past the tick-label room, so a // titled radial axis keeps its gutter whole rather than part-reclaimed. const yAxis = this._axis("y") || {}; - const titled = !!yAxis.label; + const titled = this._axisTitleVisible(yAxis); // `canvasX0` is a left legend gutter; the label room still applies inside // it. With no gutter it is 0 and `side >= room`, so this is the old value. const left = Math.max(titled ? Math.max(side, p.x) : side, canvasX0 + room); @@ -1006,7 +1011,8 @@ export class ChartView { const xAxis = this._axis("x") || {}; // A horizontal colorbar hangs off the plot's bottom edge; extending the // rect downward would walk it off the canvas. - const keepsBottom = !!xAxis.label || this.spec?.colorbar?.orientation === "horizontal"; + const keepsBottom = this._axisTitleVisible(xAxis) + || this.spec?.colorbar?.orientation === "horizontal"; const bottomReserve = keepsBottom ? reservedBottom : Math.min(reservedBottom, reservedTop); const bottom = canvasH - Math.max(room, bottomReserve); const top = reservedTop + room; @@ -1074,10 +1080,17 @@ export class ChartView { let room = 0; for (const axis of Object.values(this.axes || {})) { if (!axis || !String(axis.id || "").startsWith("y")) continue; - const labelsOnLeft = this._axisTickLabelSides(axis).includes("left"); - const titleOnLeft = axis.side !== "right"; - if (!labelsOnLeft && !titleOnLeft) continue; - if (this._axisTickLabelStrategy(axis) === "none") continue; + const labelsOnLeft = this._axisTickLabelSides(axis).includes("left") + && this._axisTickLabelsVisible(axis); + // The title is reserved separately from the tick labels, so it answers to + // its own paint: `show=False, grid=True` keeps the grid and neither text. + const titleOnLeft = axis.side !== "right" && this._axisTitleVisible(axis); + // Marks drawn into the left gutter need it as much as text does, and + // this room is MEASURED rather than a flat band, so `_axisGutterVisible` + // ruling the axis in is not enough — without their length here the ticks + // are painted left of `plot.x` and clipped at zero padding. + const leftTickRoom = this._axisOutwardTickRoom(axis, "left"); + if (!labelsOnLeft && !titleOnLeft && leftTickRoom <= 0) continue; const size = Math.max( 8, this._axisStyleNumber( @@ -1109,9 +1122,7 @@ export class ChartView { ? outward + Math.max(0, this._axisStyleNumber(axis, "tick_padding", 4)) : 0; let needed = labelsOnLeft ? 4 + tickOffset + tickRoom : 0; - const rawPosition = axis.label_position; - const position = typeof rawPosition === "string" ? rawPosition.replace(/-/g, "_") : ""; - if (titleOnLeft && axis.label && !position.startsWith("inside_")) { + if (titleOnLeft) { const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); const gap = Number.isFinite(Number(axis.label_offset)) ? Number(axis.label_offset) @@ -1129,7 +1140,7 @@ export class ChartView { + gap + labelExtent; } - room = Math.max(room, needed); + room = Math.max(room, needed, leftTickRoom ? 4 + leftTickRoom : 0); } return room; } @@ -1146,10 +1157,26 @@ export class ChartView { for (const axis of Object.values(this.axes || {})) { if (!axis || !String(axis.id || "").startsWith("x")) continue; const titleSide = axis.side === "top" ? "top" : "bottom"; - const labelsOnSide = this._axisTickLabelSides(axis).includes(side); - if (!labelsOnSide && titleSide !== side) continue; + // Tick labels and the title each answer to their own paint. Measuring + // text that is switched off reintroduces the gutter `_axisGutterVisible` + // just collapsed: `marginBottom` below takes the MEASURED room, not the + // gated `bottomAxisRoom`, so a rotated, wrapped, or collision-stacked + // label kept its band after `show=False`. + const labelsOnSide = this._axisTickLabelSides(axis).includes(side) + && this._axisTickLabelsVisible(axis); + const titleOnSide = titleSide === side && this._axisTitleVisible(axis); + // Marks drawn into this band need it as much as the text does, and + // `tick_sides` can put them on a side the labels and the axis itself do + // not use. The flat top/bottom bands are 26-62 px, so a longer authored + // `tick_length` overran them. + const tickRoomOnSide = this._axisOutwardTickRoom(axis, side); + if (!labelsOnSide && !titleOnSide && tickRoomOnSide <= 0) continue; + // The tick-label strategy decides tick-label room and nothing else. It + // is already folded into `labelsOnSide` through `_axisTickLabelsVisible`, + // so re-testing it here only dropped the TITLE's room: `"off"` keeps its + // title (unlike `"none"`, which suppresses it), and the exporter's + // `_x_tick_label_room` returns `title_room` for exactly that case. const strategy = this._axisTickLabelStrategy(axis); - if (["none", "off"].includes(strategy)) continue; const sideAxis = { ...axis, side }; const size = Math.max( 8, @@ -1177,18 +1204,51 @@ export class ChartView { const hasMultilineTicks = items.some( (item) => this._estimateTickLabel(item.text, size).lines.length > 1, ); - const position = typeof axis.label_position === "string" - ? axis.label_position.replace(/-/g, "_") : "center"; const labelSize = Math.max(8, this._axisStyleNumber(axis, "label_size", 12)); - const labelBlock = titleSide === side && axis.label && !position.startsWith("inside_") + const labelBlock = titleOnSide ? this._estimateTickLabel(axis.label, labelSize) : null; - const labelExtra = labelBlock - ? Math.max(0, labelBlock.h - labelSize * 1.2) : 0; + + // The band the title itself needs, measured from where it is drawn: + // `p.y + p.h + 24` on the bottom, `p.y - 34` on the top (the two + // branches in `_drawAxisChrome`), plus the same 4 px canvas-edge pad the + // exporter's `_x_axis_title_room` uses. Measuring only the overflow past + // one line reserved nothing for an ordinary one-line title, so at a small + // authored padding the title was drawn past the canvas edge while the + // exporter fitted it. + // + // Only the BOTTOM takes the block height. Both renderers place an x + // title from its line-box top, so a second line grows toward the plot on + // the top side and away from it on the bottom — which is why + // `_x_axis_title_room` adds `(line_count - 1) * line_step` on one branch + // and not the other. Adding it on both (the old overflow term did) put a + // three-line top title 41 px further out than the exporter. + // Signed, not clamped: `_axisLabelCss` applies the authored value as + // given and `_x_axis_title_room` adds it as given, so a negative offset + // pulls the title toward the plot and needs less room, not more. + const titleOffset = Number.isFinite(Number(axis.label_offset)) + ? Number(axis.label_offset) : 0; + // Rounded UP to a whole pixel: the GL canvas is sized `plot.h * dpr` + // into an integer attribute, so a fractional band leaves the canvas up + // to a pixel short of the rect it is meant to cover + // (`render_smoke_nonumpy.py` asserts the two agree). Only THIS term is + // rounded — the tick-label band below has always been fractional, and + // rounding it too moved charts this change has no business moving. + const titleRoom = labelBlock + ? Math.ceil(4 + titleOffset + (side === "top" ? 34 : 24 + labelBlock.h)) + : 0; + // Preserve the long-standing flat band for ordinary horizontal text. + // An axis drawing no tick label at all qualifies as much as `auto` does: + // there is no label to force a taller band, so it keeps the flat one + // rather than measuring a tick offset for rows that do not exist. A + // title is measured either way — it is not tick-label geometry, and the + // flat band is not always big enough to hold it. + const flatTickBand = !labelsOnSide || strategy === "auto"; if ( !hasAdaptiveLayout && !hasMultilineTicks - && !labelExtra - && strategy === "auto" + && !titleRoom + && !tickRoomOnSide + && flatTickBand && this._axisTickLabelAngle(axis) === null ) { continue; @@ -1218,7 +1278,24 @@ export class ChartView { offset = outward + this._axisStyleNumber(axis, "tick_padding", 4) + (side === "top" ? size * 0.2 : size * 0.8); } - room = Math.max(room, 4 + offset + rows * (size + 4) + extent + labelExtra); + // The title's band and the tick labels' band both start at the plot + // edge, so the axis needs the larger, not their sum. + // + // The tick-label term is gated on the labels actually being drawn on + // this side. An axis reaches here with `labelsOnSide` false whenever + // `tickRoomOnSide` alone kept it in the loop, and then `items` is empty: + // the term collapses to `4 + offset`, which is not zero, because + // `offset` is measured from the OUTWARD END of the tick mark. That + // reserved a whole label's clearance past marks with no label to hold + // -- the exporter's `_x_tick_label_room` takes `4 + tick_room` and + // stops -- so the two renderers drifted by the tick padding plus the + // font's descent at every tick length. + room = Math.max( + room, + titleRoom, + tickRoomOnSide ? 4 + tickRoomOnSide : 0, + labelsOnSide ? 4 + offset + rows * (size + 4) + extent : 0, + ); } return room; } @@ -7524,6 +7601,133 @@ export class ChartView { return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined; } + // Whether an axis text paint can contribute visible ink. The visibility + // shorthands compile to TRANSPARENT CSS colors rather than to a flag + // (`_axis_visibility_style`, python/xy/components.py), so layout has to ask + // the paint whether anything will be seen — otherwise `show=False` reserves + // a gutter for text nobody can read and cannot produce the documented + // edge-to-edge sparkline. An unknown or browser-only paint stays + // conservative and keeps its room. + // Mirrors `_axis_text_paint_visible` in python/xy/_svg.py, which is why the + // SVG and PNG exporters already collapse this gutter and the browser did not. + _axisTextPaintVisible(axis, key, fallbackKey?) { + let paint = this._axisStyleValue(axis, key); + if (paint === undefined && fallbackKey) paint = this._axisStyleValue(axis, fallbackKey); + if (paint === undefined || paint === null) return true; + return parseColor(this.root, paint, [0, 0, 0, 1])[3] !== 0; + } + + // Whether this axis's tick labels claim gutter room at all: a strategy that + // draws none, or a paint that shows none, claims nothing. `none` and `off` + // are the two strategies that draw no label, and `_xAxisRoom` and + // `_axis_tick_label_room` (python/xy/_svg.py) already skip both. + _axisTickLabelsVisible(axis) { + return !["none", "off"].includes(this._axisTickLabelStrategy(axis)) + && this._axisTextPaintVisible(axis, "tick_label_color", "tick_color"); + } + + // Whether this axis claims a gutter at all. Tick labels and the axis title + // are separate paints, so either one being visible reserves the band: with + // transparent ticks and an opaque title, gating on the ticks alone drew the + // title into a gutter that no longer existed — off the canvas for a top or + // bottom axis, over the plot for a right-side one. The left gutter already + // measured the two separately (`_yAxisLeftRoom`); this is the same rule for + // the sides that reserve a flat or measured band instead. + _axisGutterVisible(axis, side = null) { + return this._axisTickLabelsVisible(axis) + || this._axisTitleVisible(axis) + || this._axisOutwardTickRoom(axis, side) > 0; + } + + // How far this axis's tick marks reach outside the plot, in px. Tick marks + // are chrome of their own: they answer to no *text* paint, so an axis with + // its labels switched off can still need the gutter for them, and the + // colorbar beside it still has to clear them. They do answer to + // `tick_color`, and to `tick_label_strategy: "none"`, which silences the + // whole axis chrome and takes both tick loops with it — geometry alone does + // not mean ink. The + // core default `tick_length` is 0, so an unstyled axis reaches nothing, and + // the `ticks=False`/`show=False` shorthand's `tick_length: 0, tick_width: 0` + // sentinel reaches nothing either. + // + // Mirrors `_axis_outward_tick_room` in python/xy/_svg.py. + _axisOutwardTickRoom(axis, side = null) { + if (this._axisTickLabelStrategy(axis) === "none") return 0; + // The requested side names its own dimension: a left/right query is about + // a y axis however the spec is shaped, and `_axisTickSides` would + // otherwise read it off the `id`. + const isX = side === null ? null : ["bottom", "top"].includes(side); + + // The two tiers are drawn by two different loops, and almost nothing + // about them is shared, so they are measured apart. The major tier is + // drawn for the computed ticks, on every `tick_sides`, in + // `style.tick_color`; the minor tier only for the primary axes' own + // `minor_tick_values`, on `side` alone, in `minor_style.tick_color`. + // Taking the larger length + // under the major tier's paint and sides reserves phantom gutters and + // clips real marks in the same expression. + let room = 0; + if (this._axisTextPaintVisible(axis, "tick_color") + && (side === null || this._axisTickSides(axis, isX).includes(side))) { + room = this._tickTierOutwardRoom(axis); + } + + // Only the primary x/y axes have a minor tier: both renderers draw minor + // marks from `minorTicks(xAxis, "x")` / `minorTicks(yAxis, "y")` alone, + // and the named-axis loops draw the major tier and stop. `_normalizeAxes` + // stamps every axis's id from its map key, so the id settles it here; the + // exporter has no such step and takes the answer from its caller instead. + const hasMinorTier = axis && (axis.id === "x" || axis.id === "y"); + if (hasMinorTier + && Array.isArray(axis.minor_tick_values) && axis.minor_tick_values.length) { + const minor = { ...axis, style: axis.minor_style || {} }; + const minorSide = axis.side || (isX === false ? "left" : "bottom"); + if (this._axisTextPaintVisible(minor, "tick_color") + && (side === null || side === minorSide)) { + room = Math.max(room, this._tickTierOutwardRoom(minor)); + } + } + return room; + } + + // One tier's outward reach, from its own `style`. + _tickTierOutwardRoom(axis) { + const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); + // Zero width draws nothing anywhere (see `tickParts`), and the + // `ticks=False`/`show=False` sentinel is `tick_length: 0, tick_width: 0`, + // so either half of it reaches nothing on its own. + if (length <= 0 || this._axisStyleNumber(axis, "tick_width", 1) <= 0) return 0; + const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + if (direction === "in") return 0; + return direction === "inout" ? length / 2 : length; + } + + // Whether this axis draws a title into the gutter, which is what makes the + // title worth reserving room for. Three separate conditions, each matched to + // what the renderers actually do: + // + // - `tick_label_strategy: "none"` suppresses the title as well as the + // labels, in both renderers (the two title branches in `_drawAxisChrome` + // below, and `_axis_label_geometry` in python/xy/_svg.py). Crediting a + // title there reserves a band nothing is drawn into. `"off"` is the + // narrower switch and keeps the title. + // - The title answers to `label_color` and nothing else: both renderers + // paint it from that key alone (`kind === "label"` below, and + // `_css(axis_style.get("label_color"), ...)`), with no `tick_color` + // fallback. Reading a transparent `tick_color` as a hidden title drops + // the gutter out from under a title that is still drawn. + // - An `inside_*` title is drawn over the plot and needs no gutter at all. + // + // Mirrors `_axis_title_visible` in python/xy/_svg.py. + _axisTitleVisible(axis) { + if (!axis || !axis.label) return false; + if (this._axisTickLabelStrategy(axis) === "none") return false; + const raw = axis.label_position; + const position = typeof raw === "string" ? raw.replace(/-/g, "_") : "center"; + if (position.startsWith("inside_")) return false; + return this._axisTextPaintVisible(axis, "label_color"); + } + _axisGridDash(axis) { const value = String(this._axisStyleValue(axis, "grid_dash") || "solid"); if (value === "dashed") return [6, 4]; @@ -7544,11 +7748,22 @@ export class ChartView { return id === "y" ? "left" : "right"; } - _axisTickSides(axis) { - const isX = String(axis && axis.id || "x").startsWith("x"); + // `isX` defaults to reading the axis `id`; a caller that already knows the + // dimension (because it named the gutter it is asking about) passes it, so + // an axis dict carrying no `id` is still asked the right question. + _axisTickSides(axis, isX = null) { + const inferred = isX === null; + if (inferred) isX = String(axis && axis.id || "x").startsWith("x"); const allowed = isX ? ["bottom", "top"] : ["left", "right"]; if (!Array.isArray(axis && axis.tick_sides)) { - return [axis && axis.side || this._axisDefaultSide(axis)]; + const authored = axis && axis.side; + if (authored) return [authored]; + // An axis that authors no side falls back to the one its id implies, + // whether or not the caller supplied the dimension -- the draw loop + // asks without a hint, so answering `allowed[0]` here would have had + // layout reserve the left gutter for a `y2` whose marks are drawn on + // the right. `allowed[0]` remains for an axis with no id to imply one. + return [axis && axis.id !== undefined ? this._axisDefaultSide(axis) : allowed[0]]; } return allowed.filter((side) => axis.tick_sides.includes(side)); } @@ -7934,8 +8149,16 @@ export class ChartView { // between throttled zoom frames since the plot rect doesn't move on zoom. const tickParts = (axis) => { const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); - const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); + // An authored zero width draws nothing, in every renderer: the SVG + // exporter emits `stroke-width="0"` and the raster one skips a + // non-positive width. The 0.5 floor is for sub-pixel widths at low dpr, + // not a way to resurrect a mark the author switched off — clamping + // through zero painted a hairline the static renderers had no gutter + // for. `_axisOutwardTickRoom` asks the same question. + const authoredWidth = this._axisStyleNumber(axis, "tick_width", 1); + const width = authoredWidth > 0 ? Math.max(0.5, authoredWidth) : 0; const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); + if (width <= 0) return { inward: 0, outward: 0, width: 0 }; if (direction === "in") return { inward: length, outward: 0, width }; if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; return { inward: 0, outward: length, width }; diff --git a/news/522.bugfix.md b/news/522.bugfix.md new file mode 100644 index 00000000..3b51267a --- /dev/null +++ b/news/522.bugfix.md @@ -0,0 +1,23 @@ +`xy.x_axis(show=False)` and `xy.y_axis(show=False)` now collapse the axis's +layout slot instead of painting an invisible axis in a gutter it still +occupies. With `padding=0` a chart whose axes are switched off reaches the +container edge — the sparkline case the axes docs describe — rather than +sitting 25 px in, and a hidden right-side axis no longer reserves its flat +54 px. Without an authored `padding` the label-aware default margins still +apply, as they always have; what changes is that a hidden axis no longer adds +its own gutter on top. The visibility shorthands compile to transparent paints +rather than to a flag, and the browser was measuring that invisible text back +into the gutter; the SVG and PNG exporters already skipped it on the left, so +the same chart exported flush and rendered inset. Both renderers now ask the +same question about the paint, on every side. `tick_label_strategy="off"` now +claims no tick-label room, the way `"none"` already did, while keeping the room +for the title it still draws — `"none"` suppresses that title too. Outward tick +marks answer to no text paint at all, so an axis that draws only those keeps +its band. A gutter is kept for an axis title only where a title is actually +drawn — not under +`tick_label_strategy="none"`, which suppresses the title as well, and not for an +`inside_*` title, which is drawn over the plot. Polar charts read the same +rules: switching the angular labels off reclaims the disc's inset whichever +switch does it, and the radial and angular titles keep their gutters only while +they are drawn. Axes that draw their text reserve exactly the room they did +before. diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 7d4301ae..1c65f9b7 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2092,12 +2092,20 @@ def _colorbar_right_axis_room( The vertical colorbar shifts right by this amount so its bar/ticks/label clear the axis tick labels (plot-right+8) and rotated axis title - (plot-right+40); the JS client applies the identical rule.""" - axes = [y_axis, *(axis for _axis_id, axis, _axis_scale in extra_y_axes)] + (plot-right+40); the JS client applies the identical rule (it reuses the + one `_rightAxisRoom` it computed in `_layout`, so it cannot drift). + + This must ask the same question `layout()` asks — hence + `_axis_gutter_visible` rather than a second spelling of it. A gutter that + layout collapses but the colorbar still steps over leaves the bar floating + 54 px out from a plot that reaches the edge.""" + # Only the primary y axis carries a minor tick tier; the named ones draw + # their major marks and stop, so they must not reserve for a minor one. + axes = [(y_axis, True), *((axis, False) for _axis_id, axis, _axis_scale in extra_y_axes)] if any( (axis.get("side", "left") == "right" or "right" in _axis_tick_label_sides(axis, is_x=False)) - and _axis_tick_label_strategy(axis) != "none" - for axis in axes + and _axis_gutter_visible(axis, "right", minor_tier=minor_tier) + for axis, minor_tier in axes ): return 42.0 if compact else 54.0 return 0.0 @@ -2135,8 +2143,12 @@ def _text_block_content(text: object, x: float, line_step: float) -> str: return "".join(lines) -def _has_outside_y_title(axis: dict[str, Any]) -> bool: - """Whether a y-axis title needs space outside the plot rectangle.""" +def _has_outside_axis_title(axis: dict[str, Any]) -> bool: + """Whether an axis title needs space outside the plot rectangle. + + The same question on either orientation: an ``inside_*`` ``label_position`` + draws the title over the plot and claims no gutter. + """ if not axis.get("label"): return False raw_position = axis.get("label_position") @@ -2165,6 +2177,155 @@ def _axis_text_paint_visible( return _paint_rgba8(_css(paint, _TEXT))[3] != 0 +def _axis_title_visible(axis: dict[str, Any]) -> bool: + """Whether this axis draws a title into its gutter. + + ``tick_label_strategy="none"`` suppresses the title along with the labels + (`_axis_label_geometry` below, and the two title branches in + `_drawAxisChrome`, js/src/50_chartview.ts); ``"off"`` is the narrower + switch and keeps it. The title is painted from ``label_color`` alone in + every renderer, with no ``tick_color`` fallback. An ``inside_*`` title is + drawn over the plot and needs no gutter. + + Mirrors ``_axisTitleVisible`` in js/src/50_chartview.ts. + """ + return ( + _has_outside_axis_title(axis) + and _axis_tick_label_strategy(axis) != "none" + and _axis_text_paint_visible(axis, "label_color") + ) + + +def _axis_tick_labels_visible(axis: dict[str, Any]) -> bool: + """Whether this axis draws any tick label. + + ``"none"`` and ``"off"`` are the two strategies that draw none, and a + transparent paint shows none. Mirrors ``_axisTickLabelsVisible`` in + js/src/50_chartview.ts. + """ + return _axis_tick_label_strategy(axis) not in { + "none", + "off", + } and _axis_text_paint_visible(axis, "tick_label_color", "tick_color") + + +def _axis_outward_tick_room( + axis: dict[str, Any], + side: Optional[str] = None, + *, + minor_tier: bool, +) -> float: + """How far this axis's tick marks reach outside the plot, in px. + + Tick marks are chrome of their own: they are drawn from ``tick_length`` + and answer to no *text* paint, so an axis with its labels switched off can + still need the gutter for them. They do answer to ``tick_color``, and to + ``tick_label_strategy="none"``, which silences the whole axis chrome and + takes both tick loops with it -- geometry alone does not mean ink. Core's + default ``tick_length`` is 0, so an unstyled axis reaches nothing, and the + ``ticks=False``/``show=False`` shorthand's ``tick_length=0, tick_width=0`` + sentinel reaches nothing either. An authored ``tick_width`` of 0 draws + nothing in any renderer, so it reaches nothing too. + + The two tiers are drawn by two different loops and are measured + separately, because almost nothing about them is shared: + + ============== ===================== ============================ + .. major minor + ============== ===================== ============================ + drawn for the computed ticks ``minor_tick_values`` only + drawn on every ``tick_sides`` ``side`` alone + painted from ``style.tick_color`` ``minor_style.tick_color`` + ============== ===================== ============================ + + So a minor tier styled with no values draws nothing, one on an axis whose + ``tick_sides`` exclude its own ``side`` is still drawn there, and either + tier's paint says nothing about the other's. Taking the larger of two + lengths under the major tier's paint and sides -- as this did when the + minor tier was first counted -- reserves phantom gutters and clips real + marks in the same function. + + A *named* axis has no minor tier at all. Both renderers draw minor marks + for the primary x and y axes only (``xmt``/``ymt`` here, ``xmt``/``ymt`` + from ``minorTicks(xAxis, "x")`` in the client); the extra-axis loops draw + the major tier and stop. So ``minor_tier`` says whether this axis is one + of the two that has one, and the caller supplies it because the caller + holds the ``_axes_by_id`` key that settles it. The client's mirror reads + ``axis.id`` instead, which it may do because ``_normalizeAxes`` stamps + every axis's id from its map key; nothing normalizes a spec on this side, + so an older payload's axis dict can reach here with no ``id`` -- the same + trap the ``side`` argument above exists to avoid. + + The requested ``side`` names its own dimension -- a left/right query is + about a y axis whichever way the spec is shaped -- so it, and not the + axis's ``id``, is what picks the allowed sides. Everywhere else here the + dimension comes from the caller too (``is_x=`` at every + ``_axis_tick_label_sides`` call site), and an older payload's axis dict + need not carry an ``id`` at all. + + Mirrors ``_axisOutwardTickRoom`` in js/src/50_chartview.ts. + """ + if _axis_tick_label_strategy(axis) == "none": + return 0.0 + if side is not None: + is_x = side in ("bottom", "top") + else: + is_x = str(axis.get("id", "x")).startswith("x") + + room = 0.0 + if _axis_text_paint_visible(axis, "tick_color") and ( + side is None or side in _axis_tick_sides(axis, is_x=is_x) + ): + room = _tick_tier_outward_room(axis.get("style") or {}) + + # `minor_axis_ticks` returns nothing without `minor_tick_values`, so a + # styled-but-valueless minor tier draws no marks and needs no gutter -- + # and only the primary x/y axes have a minor tier at all, which is what + # `minor_tier` carries (see the docstring). + if minor_tier and axis.get("minor_tick_values"): + minor = {**axis, "style": axis.get("minor_style") or {}} + minor_side = axis.get("side", "bottom" if is_x else "left") + if _axis_text_paint_visible(minor, "tick_color") and (side is None or side == minor_side): + room = max(room, _tick_tier_outward_room(minor["style"])) + return room + + +def _tick_tier_outward_room(style: dict[str, Any]) -> float: + """One tick tier's outward reach, from its own style map.""" + length = max(0.0, float(style.get("tick_length", 0) or 0.0)) + # Zero width draws nothing in any renderer, and the ``ticks=False`` + # shorthand's sentinel is ``tick_length=0, tick_width=0``, so either half + # of it reaches nothing on its own. + if length <= 0.0 or float(style.get("tick_width", 1) or 0.0) <= 0.0: + return 0.0 + direction = str(style.get("tick_direction", "out")) + if direction == "in": + return 0.0 + return length / 2.0 if direction == "inout" else length + + +def _axis_gutter_visible( + axis: dict[str, Any], + side: Optional[str] = None, + *, + minor_tier: bool, +) -> bool: + """Whether this axis claims a gutter at all. + + Tick labels and the title are separate paints, so either one showing keeps + the band: an opaque title over transparent ticks would otherwise be drawn + into a gutter that no longer exists. So do outward tick MARKS, which have + no text paint at all — an axis that draws only those still needs somewhere + to draw them, and the colorbar beside it still has to clear them. + Mirrors ``_axisGutterVisible`` in js/src/50_chartview.ts. + """ + return ( + _axis_tick_labels_visible(axis) + or _axis_title_visible(axis) + or _axis_outward_tick_room(axis, side, minor_tier=minor_tier) > 0.0 + ) + + def _y_title_baseline( axis: dict[str, Any], plot: dict[str, float], @@ -2176,7 +2337,7 @@ def _y_title_baseline( browser positions a centered line box; the returned coordinate includes that box-to-baseline correction. """ - if not _has_outside_y_title(axis): + if not _has_outside_axis_title(axis): return None # absent or drawn over the plot; it needs no gutter style = axis.get("style") or {} font_size = float(style.get("label_size", 12)) @@ -2251,14 +2412,17 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: continue left_labels = "left" in _axis_tick_label_sides(axis, is_x=False) left_title = axis.get("side", "left") != "right" - if not left_labels and not left_title: + # Marks drawn into the left gutter need it as much as text does, and + # this room is MEASURED rather than a flat band, so eligibility alone + # does not reserve it. Mirrors `_yAxisLeftRoom` in + # js/src/50_chartview.ts. + left_tick_room = _axis_outward_tick_room(axis, "left", minor_tier=axis_id == "y") + if not left_labels and not left_title and left_tick_room <= 0.0: continue + if left_tick_room > 0.0: + room = max(room, _AXIS_TEXT_EDGE_PAD + left_tick_room) tick_offset, tick_room = _y_tick_label_room(axis, plot_h) if left_labels else (0.0, 0.0) - title_visible = ( - left_title - and _has_outside_y_title(axis) - and _axis_text_paint_visible(axis, "label_color") - ) + title_visible = left_title and _axis_title_visible(axis) if not title_visible: if tick_offset == 0.0 and tick_room == 0.0: continue @@ -2288,11 +2452,7 @@ def _x_axis_title_room(axis: dict[str, Any]) -> float: outer glyph edge here so tight/constrained layout does not stop at the historical 36/42 px band while the title itself extends past the canvas. """ - if not axis.get("label") or not _axis_text_paint_visible(axis, "label_color"): - return 0.0 - raw_position = axis.get("label_position") - position = raw_position if isinstance(raw_position, str) else "center" - if position.replace("-", "_").startswith("inside_"): + if not _axis_title_visible(axis): return 0.0 style = axis.get("style") or {} font_size = float(style.get("label_size", 12)) @@ -2313,7 +2473,7 @@ def _x_axis_title_room(axis: dict[str, Any]) -> float: ) -def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: +def _x_tick_label_room(axis: dict[str, Any], plot_w: float, *, minor_tier: bool) -> float: """Outward room needed by the x axis's final tick-label set and title. The old 32/42 px bands only fit horizontal labels. Measure the strings and @@ -2325,7 +2485,14 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: strategy = _axis_tick_label_strategy(axis) if strategy == "none": return 0.0 - title_room = _x_axis_title_room(axis) + # Marks drawn into this band need it as much as the text does, and the + # flat 32/42 px bands are smaller than a long authored ``tick_length``. + # Mirrors the ``tickRoomOnSide`` term in `_xAxisRoom`. + tick_room = _axis_outward_tick_room(axis, axis.get("side", "bottom"), minor_tier=minor_tier) + title_room = max( + _x_axis_title_room(axis), + _AXIS_TEXT_EDGE_PAD + tick_room if tick_room > 0.0 else 0.0, + ) if strategy == "off" or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color"): return title_room if ( @@ -2460,13 +2627,20 @@ def _x_axis_rooms( continue title_side = axis.get("side", "bottom") room_sides = set(_axis_tick_label_sides(axis, is_x=True)) + # `tick_sides` can put marks on a side the labels and the axis itself + # do not use, and that side still needs its band. + room_sides.update( + side + for side in _axis_tick_sides(axis, is_x=True) + if _axis_outward_tick_room(axis, side, minor_tier=axis_id == "x") > 0.0 + ) if _axis_tick_label_strategy(axis) == "off" or axis.get("label"): room_sides.add(title_side) for side in room_sides: side_axis = {**axis, "side": side} if side != title_side: side_axis.pop("label", None) - measured = _x_tick_label_room(side_axis, plot_w) + measured = _x_tick_label_room(side_axis, plot_w, minor_tier=axis_id == "x") if side == "top": top = max(top, 26.0 if compact else 32.0, measured) else: @@ -2599,7 +2773,12 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: axis.get("side", "right") == "right" or "right" in _axis_tick_label_sides(axis, is_x=False) ) - and _axis_tick_label_strategy(axis) != "none" + # An axis whose text is switched off draws none of what this gutter + # exists to hold, so it claims none of it — the same question the left + # gutter already asks (`_axis_text_paint_visible`). Only the *presence* + # of the reservation answers to the paint; its flat 42/54 width, and + # the plot-relative right title that depends on it, are unchanged. + and _axis_gutter_visible(axis, "right", minor_tier=axis_id == "y") for axis_id, axis in axes.items() ): # Match ChartView._layout(): one shared right-side gutter contains the @@ -2812,7 +2991,9 @@ def _recut_polar_plot( # legend fell back to the plain plot rect and drew on top of the marks — # and the disc kept the cartesian gutters it should have given back. Track # it and skip only the inset. - labels_hidden = theta_axis.get("tick_label_strategy") == "none" + # The same question every cartesian gutter asks: `"off"` draws no angular + # label any more than `"none"` does, and neither does a transparent paint. + labels_hidden = not _axis_tick_labels_visible(theta_axis) # The legend gutter is taken off the canvas edge FIRST, before the disc is # fitted to what is left, so the disc never occupies the gutter and the # legend never occupies the disc. Recorded as four floats rather than a @@ -2867,7 +3048,7 @@ def _recut_polar_plot( # at x = -10, off the canvas. Charts with no radial title (the common case) # still get the full reclaim. y_axis = spec.get("y_axis") or {} - titled = bool(y_axis.get("label")) and _axis_text_paint_visible(y_axis, "label_color") + titled = _axis_title_visible(y_axis) # `canvas_x0` is a left legend gutter; the label room still applies inside it. # With no gutter it is 0 and `side >= room`, so this is the previous value. left = max(max(side, plot["x"]) if titled else side, canvas_x0 + room) @@ -2877,7 +3058,7 @@ def _recut_polar_plot( # because that title is drawn in the bottom gutter and reclaiming the band # pushed it below the canvas edge. x_axis = spec.get("x_axis") or {} - x_titled = bool(x_axis.get("label")) and _axis_text_paint_visible(x_axis, "label_color") + x_titled = _axis_title_visible(x_axis) # A horizontal colorbar is placed relative to the plot's BOTTOM edge, so # extending the rect downward walks it off the canvas. Its gutter is real # chrome, not a tick-label gutter: keep it whole, like a theta title. diff --git a/spec/api/styling.md b/spec/api/styling.md index 2bbf64c8..36777f64 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -314,9 +314,34 @@ xy.y_axis(show=False, grid=True) # horizontal guides only xy.x_axis(line=False, ticks=False, style={"grid_color": "#1e293b"}) ``` -The switches control what is *painted*, not the layout: the plot rect is -unchanged, because the gutters are reserved by `padding`. An edge-to-edge -sparkline is `show=False` **plus** `padding=0`. +The switches control what is *painted*, and layout follows the paint: an axis +with nothing left to draw reserves nothing, so its automatic gutter collapses +on every side. Authored `padding` is untouched — it is a floor the switches +never lower — so an edge-to-edge sparkline is still `show=False` **plus** +`padding=0`, and with no `padding` the label-aware defaults below continue to +apply. Every renderer decides this the same way, from the compiled paint +rather than from a flag: a transparent tick-label paint, or a +`tick_label_strategy` of `"none"` or `"off"`, claims no tick-label room. The +axis title is reserved separately and answers to `label_color` alone, so an +opaque title over switched-off ticks keeps the band it is drawn in — unless it +is not drawn at all, which is the case for `tick_label_strategy="none"` (which +suppresses the title too, where `"off"` keeps it) and for an `inside_*` +`label_position` (drawn over the plot, so it needs no band). A title that *is* +drawn reserves the band it is drawn in, measured: the automatic gutters are a +floor for ordinary text, not a ceiling the title is clipped against. Outward +tick marks answer to no text paint at all, so an axis whose labels are switched +off while its `tick_length` still draws marks keeps its band for them. The two +tick tiers are reserved independently, because they are drawn independently: +the major tier is drawn for the computed ticks, on every `tick_sides`, in +`style`'s `tick_color`, while the minor tier is drawn only for the positions +`minor_tick_values` supplies, on `side` alone, in `minor_style`'s own +`tick_color`. So a `minor_style` with no `minor_tick_values` paints nothing +and claims nothing; a minor tier with values claims its reach whatever the +major tier's paint says; and an axis whose `tick_sides` send its major marks +to the opposite edge still keeps the band its minor marks are drawn in. Only +the primary `x` and `y` axes have a minor tier: a named axis draws its major +marks and stops, so it reserves for those and never for a minor tier it will +not paint. ### Plot rectangle and chrome reservations @@ -432,7 +457,11 @@ Two asymmetries are deliberate, not oversights: plot-relative (`plot-right + 40`) rather than to a canvas inset, so widening only the static exporters' right gutter would move their title away from the browser's. Unusually wide right-side tick labels can therefore still meet - their axis title, in every renderer alike. + their axis title, in every renderer alike. The flat *width* is what this + fixes; whether the gutter is reserved at all still answers to the paint, as + the left gutter does — an axis whose text is switched off holds none of what + the gutter exists for and claims none of it, in the browser and the exporters + alike. - **Only a spec-authored `padding` reaches the browser.** `layout()` is a Python function; a chart rendered live with `padding=None` gets ChartView's own `46/62` default, not the measured floor. The pyplot shim closes that gap on the diff --git a/spec/assets/axis-show-sparkline-before-after.png b/spec/assets/axis-show-sparkline-before-after.png new file mode 100644 index 00000000..17dfde3b Binary files /dev/null and b/spec/assets/axis-show-sparkline-before-after.png differ diff --git a/spec/design/polar-axes.md b/spec/design/polar-axes.md index c92a3126..150a67c6 100644 --- a/spec/design/polar-axes.md +++ b/spec/design/polar-axes.md @@ -142,12 +142,19 @@ Eight properties this pins down, each of which has matching coverage: full text stays in `title`/ARIA. Nothing is reserved when the author supplied an `anchor` (an explicit plot-relative placement they own, still resolved against the plot) or a four-tuple `padding` (which already states the box the plot - should occupy, and remains the way to hand-reserve a caption band), and nothing - is reserved for a figure whose angular axis is `tick_label_strategy="none"` — - that early return skips the whole recut, and it is the donut/gauge case whose - chrome the author has already taken over. Both static exporters bound their - legend so an oversized one ellipsizes instead of escaping the file, and that - bound is `legend_clip_rect` — the plot rect **unioned** with the gutter, shared + should occupy, and remains the way to hand-reserve a caption band), and no + tick-label room is reserved for a figure whose angular axis draws no tick + label — a `tick_label_strategy` of `"none"` or `"off"`, or a tick-label paint + the visibility shorthands have made transparent. That is the same question every + cartesian gutter asks (`_axis_tick_labels_visible` / `_axisTickLabelsVisible`), + and it is the donut/gauge case whose chrome the author has already taken over. + Only that inset goes: a legend gutter is still taken, the figure-title band + above the disc is never reclaimed, and the radial and angular titles answer + separately, to `_axis_title_visible` — a drawn title keeps the gutter it is + placed in, while a hidden or `inside_*` one keeps none. Both static exporters + bound their legend so an oversized one ellipsizes instead of escaping the + file, and that bound is `legend_clip_rect` — the plot rect **unioned** with + the gutter, shared so the SVG `clipPath` and the raster clip command cannot drift. Clipping to the plot rect alone is not a smaller legend but no legend: the gutter is outside it by construction, so the whole box falls away. diff --git a/tests/test_axis_show_layout.py b/tests/test_axis_show_layout.py new file mode 100644 index 00000000..a95d05d4 --- /dev/null +++ b/tests/test_axis_show_layout.py @@ -0,0 +1,804 @@ +"""`show=False` collapses the axis's layout slot (dossier §33, axes docs). + +The visibility shorthands compile to transparent axis paints rather than to a +flag, so layout has to ask the paint whether any text will be seen. The static +exporters already do (`_axis_text_paint_visible`, whose docstring names the +edge-to-edge sparkline as the reason); the browser did not, so the same chart +rendered flush in an SVG and inset in a canvas. These tests pin the two to each +other. + +Browser probes drive the real client; they skip (never fail) without Chromium, +like the repo's others. +""" + +from __future__ import annotations + +import os +import re +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from conftest import probe_document, run_browser_probe + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy.export import find_chromium # noqa: E402 + +WIDTH = 1088 +HEIGHT = 200 +PADDING = (8, 0, 28, 0) + + +def _chart(**axes): + """The reported repro: a line with both axes switched off, no padding to + spare, and a fluid width so the plot can reach the container edge.""" + return xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(**axes.get("x", {})), + xy.y_axis(**axes.get("y", {})), + width=WIDTH, + height=HEIGHT, + padding=PADDING, + ) + + +def _svg_plot_rect(chart) -> tuple[float, float, float, float]: + """The exporter's plot rect (x, y, width, height), read from the clip path + it draws marks into. All four, not just the horizontal pair: collapsing a + gutter on one axis while the other renderer keeps it is exactly the class + of divergence this fixes, and it can happen vertically too.""" + svg = chart.to_svg() + match = re.search(r"]*>\s*", svg) + assert match is not None, "SVG clip path shape changed; update this helper" + attrs = dict(re.findall(r'(\w+)="([^"]*)"', match.group(1))) + return tuple(round(float(attrs[k]), 1) for k in ("x", "y", "width", "height")) + + +def test_export_collapses_the_slot_for_show_false() -> None: + """The contract the exporters already keep: nothing drawn, nothing + reserved, on either side.""" + off = {"show": False} + assert _svg_plot_rect(_chart(x=off, y=off))[:3] == (0.0, PADDING[0], WIDTH) + flush_right = _chart(x=off, y={"show": False, "side": "right"}) + assert flush_right and _svg_plot_rect(flush_right)[2] == WIDTH + # A grid needs no gutter, so `show=False, grid=True` is flush as well. + grid_only = _chart(x=off, y={"show": False, "grid": True}) + assert _svg_plot_rect(grid_only)[2] == WIDTH + # An axis that draws its labels still reserves its room. + x0, _y, w, _h = _svg_plot_rect(_chart()) + assert x0 > 0 and w < WIDTH, (x0, w) + + +def test_an_axis_title_keeps_its_gutter_without_tick_labels() -> None: + """Tick labels and the axis title are separate paints, so either one + showing keeps the band. Gating the whole gutter on the tick labels drew an + opaque title into a gutter that no longer existed — over the plot, for a + right-side axis.""" + titled_right = xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(show=False), + xy.y_axis(show=False, side="right", label="Value", style={"label_color": "#000000"}), + width=WIDTH, + height=HEIGHT, + padding=(0, 0, 0, 0), + ) + # The title is drawn, so the flat right-side reservation stands. + assert _svg_plot_rect(titled_right)[2] < WIDTH + # With no title to draw, it does not. + untitled_right = xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(show=False), + xy.y_axis(show=False, side="right"), + width=WIDTH, + height=HEIGHT, + padding=(0, 0, 0, 0), + ) + assert _svg_plot_rect(untitled_right)[2] == WIDTH + + +_PLOT_RECT_PROBE = """ + +""" + + +def _browser_plot_rect(chart, label: str) -> tuple[float, float, float, float]: + """The client's plot rect (x, y, width, height), in the same order as + `_svg_plot_rect`.""" + chromium = find_chromium() + if not chromium: + # `run_browser_probe` turns an unlaunchable browser into a failure under + # XY_REQUIRE_BROWSER; skipping before that call would slip past it. + if os.environ.get("XY_REQUIRE_BROWSER"): + pytest.fail(f"{label}: XY_REQUIRE_BROWSER is set but no chromium was found") + pytest.skip(f"no chromium available for the {label} probe") + document = probe_document(chart, _PLOT_RECT_PROBE) + with tempfile.TemporaryDirectory() as td: + payload = run_browser_probe( + chromium, document, Path(td) / "axis.html", "data-xy-plotrect", label=label + ) + return tuple(round(float(payload[k]), 1) for k in ("x", "y", "w", "h")) + + +def test_browser_show_false_reaches_the_edge_like_the_export() -> None: + """The divergence this fixes: the browser reserved a gutter for text the + shorthands had already made invisible, so a chart that exported flush + rendered inset. Both sides now answer the same question about the paint.""" + off = {"show": False} + assert _browser_plot_rect(_chart(x=off, y=off), "axes off")[:3] == ( + 0.0, + float(PADDING[0]), + float(WIDTH), + ) + + # The right side was the worse case: a flat 54 px reservation that no style + # zeroing could reach. + right = _chart(x=off, y={"show": False, "side": "right"}) + assert _browser_plot_rect(right, "right axis off")[2] == float(WIDTH) + + # `show=False, grid=True` keeps the grid and still claims no gutter. + grid = _chart(x=off, y={"show": False, "grid": True}) + assert _browser_plot_rect(grid, "grid only")[2] == float(WIDTH) + + +def test_browser_visible_axes_keep_their_room() -> None: + """The other half of the contract: an axis whose text is drawn reserves the + room it needs, and the browser agrees with the exporter about how much.""" + chart = _chart() + browser = _browser_plot_rect(chart, "axes on") + assert browser[0] > 0 and browser[2] < WIDTH, browser + export = _svg_plot_rect(chart) + # The two measure text with different engines, so they are close rather + # than identical; a whole gutter's worth of difference is the regression. + assert abs(browser[0] - export[0]) <= 8, (browser, export) + assert abs(browser[2] - export[2]) <= 8, (browser, export) + + +def test_browser_and_export_agree_on_every_side() -> None: + """Parity in all four coordinates, not just the horizontal pair: collapsing + a gutter in one renderer and not the other is the bug this fixes, and it + can happen vertically. + + Titled axes join the sweep: the browser used to measure only a title's + overflow past one line, so a one-line title reserved nothing while the + exporter fitted it. `test_a_title_reserves_the_band_it_is_drawn_in` covers + that case directly.""" + off = {"show": False} + for label, axes in ( + ("both off", {"x": off, "y": off}), + ("x off, y on", {"x": off, "y": {}}), + ("x on, y off", {"x": {}, "y": off}), + ("titled x", {"x": {"label": "Time"}, "y": off}), + ("titled both", {"x": {"label": "Time"}, "y": {"label": "Value"}}), + ("right axis off", {"x": off, "y": {"show": False, "side": "right"}}), + ("grid only", {"x": off, "y": {"show": False, "grid": True}}), + ): + chart = xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(**axes["x"]), + xy.y_axis(**axes["y"]), + width=WIDTH, + height=HEIGHT, + padding=(0, 0, 0, 0), + ) + _assert_parity( + f"parity: {label}", + _browser_plot_rect(chart, f"parity: {label}"), + _svg_plot_rect(chart), + ) + + +# The two renderers measure text with different engines, so a coordinate pair +# is "the same" within a few pixels; a whole gutter's worth apart is the +# regression these tests exist for. Never `==`: exact equality holds only while +# the rounding in the helpers above happens to mask a sub-0.05 px difference, +# and it would flake on another font stack or platform. +_PARITY_TOLERANCE_PX = 8.0 + + +def _assert_parity(label: str, browser, export) -> None: + for axis_name, index in (("x", 0), ("y", 1), ("width", 2), ("height", 3)): + assert abs(browser[index] - export[index]) <= _PARITY_TOLERANCE_PX, ( + label, + axis_name, + browser, + export, + ) + + +def _parity_chart(**axes): + return xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(**axes.get("x", {})), + xy.y_axis(**axes.get("y", {})), + width=WIDTH, + height=HEIGHT, + padding=(0, 0, 0, 0), + ) + + +def test_browser_measures_no_band_for_labels_it_will_not_draw() -> None: + """The gutter eligibility check is not the only place the room is decided. + + The bottom margin takes the *measured* x-axis room directly, so an axis + that was ruled ineligible could still claim a band through the measuring + path — `show=False` plus an angle sent every label through rotation layout + and reserved the extent of text nobody can see. A 45 degree hidden axis + lost 41.7 px of plot height that the exporter kept.""" + hidden = _parity_chart(x={"show": False, "tick_label_angle": 45}, y={"show": False}) + assert _browser_plot_rect(hidden, "rotated hidden x")[3] == float(HEIGHT) + assert _svg_plot_rect(hidden)[3] == float(HEIGHT) + # The same axis with its paint left alone still reserves its rotated band, + # in both renderers, to within their text-measurement difference. + shown = _parity_chart(x={"tick_label_angle": 45}, y={"show": False}) + browser = _browser_plot_rect(shown, "rotated visible x") + export = _svg_plot_rect(shown) + assert browser[3] < HEIGHT and export[3] < HEIGHT, (browser, export) + assert abs(browser[3] - export[3]) <= 8, (browser, export) + + +def test_tick_label_strategy_off_claims_no_gutter() -> None: + """`off` and `none` both draw no tick label, so both claim no room. + + Only `none` was excluded, so an axis switched off through the strategy kept + a 25.5 px left inset in the browser that the exporter had already dropped — + the same divergence as the transparent paint, reached by the other door.""" + off = {"tick_label_strategy": "off"} + chart = _parity_chart(x=off, y=off) + assert _browser_plot_rect(chart, "strategy off")[:3] == (0.0, 0.0, float(WIDTH)) + assert _svg_plot_rect(chart)[:3] == (0.0, 0.0, float(WIDTH)) + # A right-side axis reaches the same answer through the flat 42/54 band. + right = _parity_chart(x={"show": False}, y={**off, "side": "right"}) + assert _browser_plot_rect(right, "right strategy off")[2] == float(WIDTH) + assert _svg_plot_rect(right)[2] == float(WIDTH) + + +def test_a_title_answers_to_label_color_alone() -> None: + """Both renderers paint an axis title from `label_color` and nothing else. + + Reading the title's visibility through a `tick_color` fallback dropped the + gutter out from under a title that is still drawn: blanking only the tick + paints left the right-side title with nowhere to sit.""" + titled = _parity_chart( + x={"show": False}, + y={ + "side": "right", + "label": "Value", + "style": {"tick_label_color": "#00000000", "tick_color": "#00000000"}, + }, + ) + assert _browser_plot_rect(titled, "tick paint off, title on")[2] < WIDTH + assert _svg_plot_rect(titled)[2] < WIDTH + # `show=False` blanks `label_color` too, so that chart is still flush. + hidden = _parity_chart(x={"show": False}, y={"show": False, "side": "right", "label": "Value"}) + assert _browser_plot_rect(hidden, "title off")[2] == float(WIDTH) + assert _svg_plot_rect(hidden)[2] == float(WIDTH) + + +def test_tick_label_strategy_none_suppresses_the_title_and_its_gutter() -> None: + """`none` switches the title off too, so it cannot keep the gutter open. + + Crediting a title for gutter eligibility has to ask whether the title is + actually drawn. Both renderers suppress it under `tick_label_strategy` + `"none"` (`axis.label && strategy !== "none"` in the two browser title + branches; `_axis_label_geometry` in the exporter), so counting it there + reserved 54 px in the browser that nothing was ever drawn into. `"off"` is + the narrower switch and keeps the title, and its band with it.""" + for side in ("left", "right"): + hidden = _parity_chart( + x={"show": False}, + y={"side": side, "label": "Value", "tick_label_strategy": "none"}, + ) + assert _browser_plot_rect(hidden, f"{side}: none + title")[2] == float(WIDTH) + assert _svg_plot_rect(hidden)[2] == float(WIDTH) + + kept = _parity_chart( + x={"show": False}, + y={"side": side, "label": "Value", "tick_label_strategy": "off"}, + ) + browser = _browser_plot_rect(kept, f"{side}: off + title") + export = _svg_plot_rect(kept) + assert browser[2] < WIDTH and export[2] < WIDTH, (side, browser, export) + assert abs(browser[2] - export[2]) <= 8, (side, browser, export) + + +def test_an_inside_title_claims_no_gutter() -> None: + """A title placed inside the plot is drawn over it and needs no band. + + It is the third condition the gutter asks about, alongside the strategy and + the paint; `_x_axis_title_room` and `_y_axis_left_room` already skipped it, + so the browser agreeing is what keeps the two renderers together.""" + blanked_ticks = {"tick_label_color": "#00000000", "tick_color": "#00000000"} + inside = _parity_chart( + x={"show": False}, + y={ + "side": "right", + "label": "Value", + "label_position": "inside_center", + "style": blanked_ticks, + }, + ) + assert _browser_plot_rect(inside, "inside title")[2] == float(WIDTH) + assert _svg_plot_rect(inside)[2] == float(WIDTH) + # The same title placed outside does claim the band, so the assertion above + # is about the position and not about the blanked tick paints. + outside = _parity_chart( + x={"show": False}, + y={"side": "right", "label": "Value", "style": blanked_ticks}, + ) + assert _browser_plot_rect(outside, "outside title")[2] < WIDTH + assert _svg_plot_rect(outside)[2] < WIDTH + + +_WRAPPED_TITLE = "Trade settlement window\nsecond line\nthird line" + + +def test_off_does_not_zero_the_title_it_still_draws() -> None: + """The tick-label strategy decides tick-label room, not the title's. + + `"off"` keeps the axis title — that is what separates it from `"none"` — + but `_xAxisRoom` re-tested the strategy after the eligibility check and + returned no room at all. The exporter has always measured it + (`_x_tick_label_room` returns `title_room` for exactly that case), so the + two renderers disagreed by the whole band. + """ + wrapped = _WRAPPED_TITLE + for label, x_axis in ( + ("wrapped", {"tick_label_strategy": "off", "label": wrapped}), + ("one line", {"tick_label_strategy": "off", "label": "Time"}), + ("top side", {"tick_label_strategy": "off", "label": "Time", "side": "top"}), + ): + chart = _parity_chart(x=x_axis, y={"show": False}) + browser = _browser_plot_rect(chart, f"off + title, {label}") + assert browser[3] < HEIGHT, (label, browser) + _assert_parity(f"off + title, {label}", browser, _svg_plot_rect(chart)) + + # `none` suppresses the title, so it keeps the full canvas — in both. + none = _parity_chart(x={"tick_label_strategy": "none", "label": wrapped}, y={"show": False}) + assert _browser_plot_rect(none, "none + wrapped title")[3] == float(HEIGHT) + assert _svg_plot_rect(none)[3] == float(HEIGHT) + + # Tick-label geometry stays off: a rotation angle on an axis that draws no + # label claims nothing, in either renderer. + rotated = _parity_chart( + x={"tick_label_strategy": "off", "tick_label_angle": 45}, y={"show": False} + ) + assert _browser_plot_rect(rotated, "off + rotated")[3] == float(HEIGHT) + assert _svg_plot_rect(rotated)[3] == float(HEIGHT) + + +def test_a_title_reserves_the_band_it_is_drawn_in() -> None: + """The browser measured only a title's overflow past one line, so an + ordinary one-line title reserved nothing and was drawn at + `p.y + p.h + 24` — past the canvas edge at a small authored padding, while + the exporter's `_x_axis_title_room` fitted it. This was not specific to any + visibility switch: a plain titled axis reproduced it, and it is the gap the + parity sweep's docstring used to record as known and unfixed.""" + for label, x_axis in ( + ("bottom", {"label": "Time"}), + ("top", {"label": "Time", "side": "top"}), + ("wrapped", {"label": _WRAPPED_TITLE}), + ("offset", {"label": "Time", "label_offset": 12}), + ("large", {"label": "Time", "style": {"label_size": 22}}), + # Both renderers place an x title from its line-box top, so extra lines + # grow toward the plot on the top side and away from it on the bottom. + # Measuring the block height on both put a three-line top title 41 px + # further out than the exporter. + ("top wrapped", {"label": _WRAPPED_TITLE, "side": "top"}), + ( + "top wrapped, large", + { + "label": _WRAPPED_TITLE, + "side": "top", + "style": {"label_size": 24}, + }, + ), + ("top large", {"label": "Time", "side": "top", "style": {"label_size": 28}}), + ( + "bottom wrapped, large", + { + "label": _WRAPPED_TITLE, + "style": {"label_size": 24}, + }, + ), + ): + chart = _parity_chart(x=x_axis, y={"show": False}) + browser = _browser_plot_rect(chart, f"x title: {label}") + assert browser[3] < HEIGHT, (label, browser) + _assert_parity(f"x title: {label}", browser, _svg_plot_rect(chart)) + + # An `inside_*` title is drawn over the plot and still claims nothing. + inside = _parity_chart( + x={"label": "Time", "label_position": "inside_center"}, y={"show": False} + ) + assert _browser_plot_rect(inside, "inside x title")[3] == float(HEIGHT) + assert _svg_plot_rect(inside)[3] == float(HEIGHT) + + +def _colorbar_bar(**axis) -> tuple[float, float]: + """`(colorbar bar x, plot right edge)` for a heatmap beside a right y axis. + + The colorbar's clearance is the only place the right-axis gutter shows up + without a browser, so both the gutter test and the tick-mark one read it + from here rather than each rebuilding the chart. + """ + from xy import _svg + + chart = xy.chart( + xy.heatmap([[0.0, 1.0], [2.0, 3.0]], name="field", colormap="viridis"), + xy.line([0.0, 1.0], [100.0, 200.0], y_axis="y2"), + xy.y_axis(id="y2", side="right", domain=(100.0, 200.0), **axis), + xy.colorbar(title="Field"), + width=560, + height=300, + ) + figure = chart.figure() + spec, _blob = figure.build_payload() + *_rest, plot = _svg.layout(spec) + root = ET.fromstring(figure.to_svg()) + bar = next( + node for node in root.iter() if (node.get("fill") or "").startswith("url(#xy-colorbar-") + ) + return float(bar.get("x", "nan")), plot["x"] + plot["w"] + + +def test_a_collapsed_right_gutter_does_not_push_the_colorbar_out() -> None: + """The colorbar's right-axis room asks the same question layout does. + + `_colorbar_right_axis_room` carried its own spelling of the predicate, so a + right axis whose gutter layout had just collapsed still stepped the + vertical colorbar 54 px outward — a bar floating past a plot that reaches + the canvas edge. The browser reuses the single `_rightAxisRoom` it computed + in `_layout`, so only the exporter could drift. + """ + # A drawn right axis keeps its gutter, and the bar clears the rotated title + # at plot-right + 40. + x, plot_right = _colorbar_bar(label="Secondary") + assert x > plot_right + 40, (x, plot_right) + + # Switched off, there is no gutter to clear, so the bar sits against the + # plot rather than 54 px beyond where the axis used to be. + off_x, off_plot_right = _colorbar_bar(show=True, tick_label_strategy="off") + assert off_x < off_plot_right + 40, (off_x, off_plot_right) + # An inside-only title is drawn over the plot and reserves nothing either. + inside_x, inside_plot_right = _colorbar_bar( + label="Secondary", label_position="inside_center", show=False + ) + assert inside_x < inside_plot_right + 40, (inside_x, inside_plot_right) + + +_POLAR_ANGLES = [0, 60, 120, 180, 240, 300] +_POLAR_RADII = [3.0, 4.5, 2.0, 5.0, 3.5, 4.0] +_POLAR_SIZE = 420 + + +def _polar_chart(**axes): + return xy.polar_chart( + xy.line(_POLAR_ANGLES, _POLAR_RADII), + xy.theta_axis(**axes.get("theta", {})), + xy.r_axis(**axes.get("r", {})), + width=_POLAR_SIZE, + height=_POLAR_SIZE, + ) + + +def test_polar_asks_the_same_question_about_its_text() -> None: + """The polar recut reads the same visibility rules as the cartesian gutters. + + It derived its inset from the raw strategy string and its title gutters + from `axis.label` alone, so `show=False` on a polar chart kept the disc + inset for angular labels nobody could see, and a hidden or `inside_*` + radial title kept a left gutter in the browser that the exporter had + already dropped — the renderers disagreeing about the same chart, which is + the divergence this change exists to close. + """ + visible = _polar_chart() + inset = _browser_plot_rect(visible, "polar: labels on") + _assert_parity("polar: labels on", inset, _svg_plot_rect(visible)) + assert inset[0] > 8, inset + + # Every way of switching the angular labels off reclaims the same inset, + # and `"off"` is no longer the odd one out beside `"none"`. + for label, theta in ( + ("show=False", {"show": False}), + ('strategy "off"', {"tick_label_strategy": "off"}), + ('strategy "none"', {"tick_label_strategy": "none"}), + ): + chart = _polar_chart(theta=theta) + browser = _browser_plot_rect(chart, f"polar: {label}") + assert browser[0] < inset[0], (label, browser, inset) + _assert_parity(f"polar: {label}", browser, _svg_plot_rect(chart)) + + # A drawn radial title keeps the left gutter it is placed in; one that is + # hidden, or drawn inside the disc, does not. + off = {"show": False} + titled = _polar_chart(theta=off, r={"label": "Value"}) + titled_rect = _browser_plot_rect(titled, "polar: radial title") + _assert_parity("polar: radial title", titled_rect, _svg_plot_rect(titled)) + + for label, r_axis in ( + ("hidden title", {"label": "Value", "show": False}), + ("inside title", {"label": "Value", "label_position": "inside_center"}), + ): + chart = _polar_chart(theta=off, r=r_axis) + browser = _browser_plot_rect(chart, f"polar: {label}") + assert browser[0] < titled_rect[0], (label, browser, titled_rect) + _assert_parity(f"polar: {label}", browser, _svg_plot_rect(chart)) + + # The theta title holds the bottom band the same way, and only while drawn. + hidden_theta_title = _polar_chart(theta={**off, "label": "Angle"}) + browser = _browser_plot_rect(hidden_theta_title, "polar: hidden theta title") + _assert_parity("polar: hidden theta title", browser, _svg_plot_rect(hidden_theta_title)) + + +def test_outward_tick_marks_keep_a_gutter_with_no_text_at_all() -> None: + """Tick marks are chrome of their own, answering to no text paint. + + Gating the gutter on the text alone collapsed it under an axis whose labels + are switched off but whose `tick_length` still draws marks into it — so the + marks ran past the canvas edge, and a vertical colorbar beside them kept + only its 24 px gap and was overlapped by any outward length beyond that. + """ + long_ticks = {"tick_length": 40, "tick_width": 2, "tick_direction": "out"} + + def right_gutter(**axis) -> float: + chart = _parity_chart(x={"show": False}, y={"side": "right", **axis}) + browser = _browser_plot_rect(chart, "outward ticks") + export = _svg_plot_rect(chart) + _assert_parity("outward ticks", browser, export) + return WIDTH - browser[2] + + # Labels off, marks on: the band stays, and matches the one the same axis + # keeps with its labels drawn. + assert right_gutter(tick_label_strategy="off", style=long_ticks) > 0 + assert right_gutter(tick_label_strategy="off", style=long_ticks) == right_gutter( + style=long_ticks + ) + # No authored tick geometry, no marks, no band. + assert right_gutter(tick_label_strategy="off") == 0 + # Geometry is not ink. A transparent `tick_color` paints no visible stroke + # (the exporter still emits the ``, just fully transparent), and + # `tick_label_strategy="none"` drops the tick values and so the marks with + # them entirely. Either way nothing is drawn, so nothing is reserved. + assert ( + right_gutter( + tick_label_strategy="off", + style={**long_ticks, "tick_color": "#00000000"}, + ) + == 0 + ) + assert right_gutter(tick_label_strategy="none", style=long_ticks) == 0 + # Inward marks draw over the plot and need none either. + assert ( + right_gutter( + tick_label_strategy="off", + style={**long_ticks, "tick_direction": "in"}, + ) + == 0 + ) + # `show=False` compiles to the `tick_length: 0, tick_width: 0` sentinel, so + # an axis switched off that way claims nothing. (An explicit `style=` + # outranks the shorthand, so this is the shorthand on its own rather than + # the two combined.) + assert right_gutter(show=False) == 0 + assert WIDTH - _svg_plot_rect(_parity_chart(x={"show": False}, y={"show": False}))[2] == 0 + # An authored zero width draws nothing in any renderer — the exporters + # emit `stroke-width="0"` / skip a non-positive width, and the browser's + # 0.5 floor is for sub-pixel widths rather than a way to resurrect a mark + # the author switched off. + assert right_gutter(tick_label_strategy="off", style={**long_ticks, "tick_width": 0}) == 0 + + # Minor ticks carry their own geometry and their own draw loop, so a + # longer minor tier claims the band even with no major ticks at all -- + # but only once there are minor VALUES to draw. `minor_style` alone emits + # nothing in either renderer, so it must claim nothing. + def minor_gutter(**axis) -> float: + chart = _parity_chart(x={"show": False}, y={"tick_label_strategy": "off", **axis}) + browser = _browser_plot_rect(chart, "minor ticks") + _assert_parity("minor ticks", browser, _svg_plot_rect(chart)) + return WIDTH - browser[2] + + long_minor = {"tick_length": 50, "tick_width": 2} + assert ( + minor_gutter(minor_tick_values=[0.25, 0.5, 0.75], minor_style=long_minor) + > long_minor["tick_length"] + ) + assert minor_gutter(minor_style=long_minor) == 0 + assert minor_gutter(minor_tick_values=[], minor_style=long_minor) == 0 + # Each tier answers its OWN paint: the minor marks are stroked from + # `minor_style.tick_color`, so blanking the major one does not silence + # them, and blanking theirs does -- whatever the major tier says. + assert ( + minor_gutter( + minor_tick_values=[0.5], + minor_style=long_minor, + style={"tick_color": "#00000000"}, + ) + > long_minor["tick_length"] + ) + assert ( + minor_gutter( + minor_tick_values=[0.5], + minor_style={**long_minor, "tick_color": "#00000000"}, + ) + == 0 + ) + # And its own side. Minor marks are drawn on `side` alone, never on + # `tick_sides`, so a left axis told to put its MAJOR marks on the right + # still draws its minor ones at the left edge. + minor_left = _parity_chart( + x={"show": False}, + y={ + "tick_label_strategy": "off", + "tick_sides": ["right"], + "minor_tick_values": [0.5], + "minor_style": long_minor, + }, + ) + minor_left_rect = _browser_plot_rect(minor_left, "minor left, major right") + _assert_parity("minor left, major right", minor_left_rect, _svg_plot_rect(minor_left)) + assert minor_left_rect[0] > long_minor["tick_length"], minor_left_rect + # `tick_sides` decides which gutter the marks go in. A right-side axis + # drawing its ticks on the LEFT claims the left band and leaves the right + # edge flush — `right_gutter` above sums both sides, so this one reads the + # rect directly. + left_ticked = _parity_chart( + x={"show": False}, + y={ + "side": "right", + "tick_label_strategy": "off", + "tick_sides": ["left"], + "style": long_ticks, + }, + ) + browser = _browser_plot_rect(left_ticked, "right axis, left ticks") + _assert_parity("right axis, left ticks", browser, _svg_plot_rect(left_ticked)) + assert browser[0] > 0, browser + assert browser[0] + browser[2] == float(WIDTH), browser + + # The same on x: `tick_sides: ["top"]` on a bottom axis reserves the TOP + # band, which the flat 26/32 px default is too small to hold at + # `tick_length=40`. + top_ticked = _parity_chart( + x={"tick_label_strategy": "off", "tick_sides": ["top"], "style": long_ticks}, + y={"show": False}, + ) + top_browser = _browser_plot_rect(top_ticked, "x ticks on top only") + _assert_parity("x ticks on top only", top_browser, _svg_plot_rect(top_ticked)) + assert top_browser[1] > long_ticks["tick_length"], top_browser + + # The colorbar clears the marks rather than sitting on them. + bar_x_px, plot_right = _colorbar_bar(tick_label_strategy="off", style=long_ticks) + assert bar_x_px - plot_right > long_ticks["tick_length"], (bar_x_px, plot_right) + + +def test_the_gutter_asked_about_names_its_own_dimension() -> None: + """A per-side query answers for that side's dimension, not the spec's `id`. + + `tick_sides` is filtered against the sides the axis's dimension can use, + and every other helper here takes that dimension from its caller (`is_x=` + at each `_axis_tick_label_sides` call site) because the caller already + knows it from the loop key. Reading it from `axis["id"]` instead broke on + the payload shape `_axes_by_id` exists to accept: an older spec's + `y_axis` dict carries no `id`, so it defaulted to `x`, the allowed sides + became bottom/top, and a y axis drawing 8 px of marks answered 0 px for + both of its own gutters. The queried side settles it with no `id` at all. + """ + from xy._svg import _axis_outward_tick_room as _room + + def _axis_outward_tick_room(axis, side, minor_tier=False): + return _room(axis, side, minor_tier=minor_tier) + + marks = {"tick_length": 8, "tick_width": 1, "tick_direction": "out"} + legacy_y = {"side": "left", "tick_sides": ["left", "right"], "style": marks} + assert _axis_outward_tick_room(legacy_y, "left") == 8 + assert _axis_outward_tick_room(legacy_y, "right") == 8 + # Still filtered: the marks are on the y gutters, so neither x band is + # asked to hold them. + assert _axis_outward_tick_room(legacy_y, "bottom") == 0 + assert _axis_outward_tick_room(legacy_y, "top") == 0 + + legacy_x = {"side": "bottom", "tick_sides": ["bottom"], "style": marks} + assert _axis_outward_tick_room(legacy_x, "bottom") == 8 + assert _axis_outward_tick_room(legacy_x, "top") == 0 + assert _axis_outward_tick_room(legacy_x, "left") == 0 + + # An axis that authors no `tick_sides` draws into its own side only, and + # that answer also holds without an `id` to read it from. + bare_y = {"side": "right", "style": marks} + assert _axis_outward_tick_room(bare_y, "right") == 8 + assert _axis_outward_tick_room(bare_y, "left") == 0 + + +def test_a_named_axis_has_no_minor_tier_to_reserve_for() -> None: + """Only the primary x/y axes draw minor ticks, so only they reserve for them. + + Both renderers take their minor positions from the primary axes alone -- + `minorTicks(xAxis, "x")` / `minorTicks(yAxis, "y")` in the client, `xmt` + and `ymt` in the exporter -- while the named-axis loops draw the major + tier and stop. Measuring the minor tier for every axis therefore gave a + named axis a band for marks that no renderer emits, which is the phantom + gutter this module exists to remove. The major tier is different: named + axes really do draw it, so their room for it stays. + """ + long_ticks = {"tick_length": 50, "tick_width": 2} + + def right_gutter(axis_id: str, **axis) -> float: + chart = xy.line_chart( + xy.line([0.0, 1.0], [0.0, 1.0]), + xy.x_axis(show=False), + xy.y_axis(id=axis_id, side="right", tick_label_strategy="off", **axis), + width=WIDTH, + height=HEIGHT, + padding=(0, 0, 0, 0), + ) + svg = chart.to_svg() + drawn = svg.count('data-xy-tick="minor"') + rect = _svg_plot_rect(chart) + browser = _browser_plot_rect(chart, f"{axis_id} minor tier") + _assert_parity(f"{axis_id} minor tier", browser, rect) + return drawn, WIDTH - rect[0] - rect[2] + + minor = {"minor_tick_values": [0.25, 0.5, 0.75], "minor_style": long_ticks} + # The primary y axis draws its minor marks, so it keeps the band. + assert right_gutter("y", **minor) == (3, 54.0) + # A named axis draws none of them, so it claims nothing for them. + assert right_gutter("y2", **minor) == (0, 0.0) + # Its MAJOR marks are drawn by the extra-axis loop, and still reserved. + assert right_gutter("y2", style=long_ticks)[1] == 54.0 + + +def test_marks_with_no_label_reserve_the_marks_and_not_a_label() -> None: + """An axis kept in the band by its tick MARKS reserves only those. + + `_xAxisRoom` skips an axis that draws nothing on a side, but outward tick + marks are a third reason to stay, so an axis reaches the room expression + with its labels switched off. Its tick-label term does not collapse to + zero when it does: `items` is empty, but the term is `4 + offset`, and + `offset` is measured from the outward END of the mark (matplotlib's rule, + shared with the exporter). So the browser reserved a whole label's + clearance past marks that have no label, growing with `tick_length`, + while `_x_tick_label_room` took `4 + tick_room` and stopped. + + Every outward-tick test beside this one measures a y axis's right gutter, + which is why the x band drifted unnoticed. The lengths here span short + marks, where the phantom label dominated, to long ones, where it merely + added to them -- the gap was the same at all three. + """ + for length in (5, 10, 40): + marks = {"tick_length": length, "tick_width": 2} + chart = _parity_chart( + x={"tick_label_strategy": "off", "style": marks}, + y={"show": False}, + ) + browser = _browser_plot_rect(chart, f"x marks, no labels, {length}px") + export = _svg_plot_rect(chart) + _assert_parity(f"x marks, no labels, {length}px", browser, export) + # Parity is exact here, not merely inside the tolerance: both + # renderers reserve the edge pad plus the marks' reach and nothing + # else, so the band is 4 + length on both sides of the comparison. + assert browser[3] == export[3] == float(HEIGHT) - (4 + length), ( + length, + browser, + export, + ) diff --git a/tests/test_polar_audit_fixes.py b/tests/test_polar_audit_fixes.py index c8630dbc..3602a02f 100644 --- a/tests/test_polar_audit_fixes.py +++ b/tests/test_polar_audit_fixes.py @@ -657,8 +657,11 @@ def test_hiding_angular_labels_keeps_the_legend_gutter() -> None: _cw, _ch, _cc, cart_plot = _svg.layout(cart_spec) assert plot["x"] < cart_plot["x"] - # The client tracks the same flag rather than returning early. - assert 'const labelsHidden = this._axisTickLabelStrategy(xAxisSpec) === "none";' in CHARTVIEW + # The client tracks the same flag rather than returning early, and reads it + # from the same visibility helper every cartesian gutter uses, so `"off"` + # and a transparent tick paint reclaim the inset too + # (`test_polar_asks_the_same_question_about_its_text`). + assert "const labelsHidden = !this._axisTickLabelsVisible(xAxisSpec);" in CHARTVIEW assert "labelsHidden ? 0 : this._polarLabelRoom(xAxisSpec)" in CHARTVIEW