From 69ec1f968a612805369886df6fcf08be9653cfc7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:33:40 +0500 Subject: [PATCH 1/4] Axis tick-label anchoring, figure-background theming, rule-label styling Engine features, honored by the live client and static SVG/PNG exports alike: - tick_label_anchor on x_axis/y_axis and as a slot style (mpl ha aliases normalize): which label edge pins to the tick; rotated tick labels pivot about the pinned edge. - theme(background=) is the figure background (mpl figure.facecolor), painting the whole card (margins, title, tick labels); --chart-bg / plot_background now scopes to the plot rect only. - Rule/annotation label styling: dash strings, vertical_align, padding offsets. Showcased by the new examples/demo-advance.ipynb (benchmark-style bar chart + ExploitBench line/scatter recreation) and its Chromium-engine PNG build at examples/exploitbench.png. --- docs/engineering/styling.md | 10 +- examples/demo-advance.ipynb | 296 +++++++++++++++++++++++++++ examples/demo.ipynb | 10 +- examples/exploitbench.png | Bin 0 -> 136039 bytes js/src/20_theme.js | 8 + js/src/50_chartview.js | 127 +++++++++--- python/xy/_figure.py | 10 + python/xy/_raster.py | 59 +++++- python/xy/_svg.py | 66 +++++- python/xy/_validate.py | 23 +++ python/xy/components.py | 33 ++- python/xy/static/index.js | 97 +++++++-- python/xy/static/standalone.js | 97 +++++++-- python/xy/styles.py | 4 +- tests/pyplot/test_layout_noops.py | 13 ++ tests/test_components.py | 24 +++ tests/test_css_mark_styles.py | 4 + tests/test_png_export.py | 50 +++++ tests/test_static_client_security.py | 6 +- tests/test_svg_export.py | 48 +++++ uv.lock | 2 +- 21 files changed, 890 insertions(+), 97 deletions(-) create mode 100644 examples/demo-advance.ipynb create mode 100644 examples/exploitbench.png diff --git a/docs/engineering/styling.md b/docs/engineering/styling.md index 0f05de11..dce258ec 100644 --- a/docs/engineering/styling.md +++ b/docs/engineering/styling.md @@ -115,6 +115,7 @@ or a CSS `px` value such as `"3px"`. | `tick_length` | Non-negative pixel length | | `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size | | `tick_direction` | `"in"`, `"out"`, or `"inout"` | +| `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. | ```python xy.x_axis( @@ -218,7 +219,7 @@ Set them on `.xy` or any ancestor: | Token | Themes | Default | | --- | --- | --- | -| `--chart-bg` | Plot background | transparent | +| `--chart-bg` | Plot-rect background only (`theme(plot_background=)`, mpl `axes.facecolor`) | transparent | | `--chart-text` | Title, tick/axis titles, legend, annotation labels | inherited text (canvas labels: `currentColor` @ 85%) | | `--chart-grid` | Grid lines (canvas) | `currentColor` @ 14% | | `--chart-axis` | Axis lines (canvas), modebar glyphs | `currentColor` @ 55% | @@ -232,6 +233,13 @@ Set them on `.xy` or any ancestor: | `--chart-annotation-text` | Annotation label color | falls back to `--chart-text` | | `--chart-cursor` / `--chart-cursor-pan` | Plot cursor (box-zoom / pan) | `crosshair` / `grab` | +The **figure background** (matplotlib's `figure.facecolor` — the whole card +including margins, title, and tick labels) is not a token: `theme(background=)` +sets the root element's CSS `background` directly, and the plot rect shows it +unless `plot_background` (`--chart-bg`) paints the rect separately. Static SVG +and PNG exports reproduce both fills (solid colors; gradients stay +browser-only), so a dark card exports dark. + The compact toolbar appears while the chart is hovered or one of its controls has keyboard focus. Drag its grip to move it within the chart. Zoom and selection modes are grouped into menus; completed lasso selections expose up diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb new file mode 100644 index 00000000..211d2fe6 --- /dev/null +++ b/examples/demo-advance.ipynb @@ -0,0 +1,296 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "source": [ + "# xy — demo\n", + "\n", + "Millions of points, interactive, in a notebook. Pan by dragging, zoom with the wheel, double-click to reset.\n", + "Shift-drag a scatter to box-select. One declarative API over one engine: the Reflex-flavored `xy.scatter_chart(...)` composition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "kernel backend: native\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "import xy\n", + "import xy.kernels\n", + "\n", + "rng = np.random.default_rng(0)\n", + "print(\"kernel backend:\", xy.kernels.BACKEND)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65c3a9b6-8b8a-483d-b158-b9699ab63188", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e48482622da54282876ce9934baaa513", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "models = [\n", + " \"GPT-5.6 Sol Ultra\",\n", + " \"GPT-5.6 Sol\",\n", + " \"Claude Mythos 5\",\n", + " \"GPT-5.6 Terra\",\n", + " \"Claude Fable 5\",\n", + " \"GPT-5.5\",\n", + " \"GPT-5.6 Luna\",\n", + " \"Claude Opus 4.8\",\n", + " \"Gemini 3.1 Pro Preview\",\n", + "]\n", + "scores = [91.9, 88.8, 88.0, 84.3, 84.3, 83.4, 82.5, 78.9, 70.7]\n", + "bar_colors = [\n", + " \"#eaf1fe\", # blue-1\n", + " \"#eaf1fe\", # blue-1\n", + " \"#47484f\", # gray-5\n", + " \"#cedffe\", # blue-2\n", + " \"#47484f\", # gray-5\n", + " \"#5477c4\", # blue-4\n", + " \"#a3befa\", # blue-3\n", + " \"#47484f\", # gray-5\n", + " \"#47484f\", # gray-5\n", + "]\n", + "\n", + "xy.bar_chart(\n", + " *[\n", + " xy.bar(\n", + " x=[m], y=[s], color=c, width=0.85, corner_radius=8, stroke=\"#f0f1f2\", stroke_width=1.5\n", + " )\n", + " for m, s, c in zip(models, scores, bar_colors, strict=True)\n", + " ],\n", + " *[\n", + " xy.text(\n", + " m,\n", + " s,\n", + " f\"{s:.1f}%\",\n", + " dx=0,\n", + " dy=-8,\n", + " anchor=\"middle\",\n", + " color=\"#ffffff\",\n", + " style={\"font_size\": \"15px\", \"font_weight\": \"500\"},\n", + " )\n", + " for m, s in zip(models, scores, strict=True)\n", + " ],\n", + " # tick_label_anchor=\"end\" pins each slanted name's trailing edge at its\n", + " # tick (mpl ha='right'), so labels hang below the axis like the reference.\n", + " xy.x_axis(label=None, tick_label_angle=-30, tick_label_anchor=\"end\", style={\"tick_length\": 6}),\n", + " xy.y_axis(\n", + " label=\"Score\",\n", + " domain=(0, 100),\n", + " style={\"tick_length\": 6},\n", + " tick_values=[0, 25, 50, 75, 100],\n", + " tick_labels=[\"0%\", \"25%\", \"50%\", \"75%\", \"100%\"],\n", + " ),\n", + " xy.legend(show=False),\n", + " # background= is the figure background (mpl figure.facecolor): it paints\n", + " # the whole card — margins, title, tick labels — and shows through the\n", + " # plot rect, so no separate plot_background is needed for a flat card.\n", + " xy.theme(\n", + " background=\"#000000\",\n", + " text_color=\"#ffffff\",\n", + " grid_color=\"transparent\",\n", + " axis_color=\"#f0f1f2\",\n", + " ),\n", + " styles={\n", + " \"title\": {\n", + " \"text_align\": \"left\",\n", + " \"padding_left\": \"40px\",\n", + " \"padding_top\": \"18px\",\n", + " \"font_size\": \"22px\",\n", + " \"font_weight\": \"700\",\n", + " },\n", + " \"tick_label\": {\"font_size\": \"13px\"},\n", + " \"axis_title\": {\"font_size\": \"15px\"},\n", + " },\n", + " # width=\"100%\" fills the notebook output row - VS Code paints a white\n", + " # backdrop behind widget outputs, so a fixed-width card would sit on white.\n", + " title=\"TerminalBench 2.1\",\n", + " width=\"100%\",\n", + " height=680,\n", + " padding=[90, 40, 160, 90],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "076acb1b", + "metadata": {}, + "source": [ + "## Reference recreation — ExploitBench (dark line-and-marker benchmark)\n", + "\n", + "Five model series as **line + scatter pairs**: `xy.line` has no `marker=`, so the\n", + "dots are a separate `xy.scatter` per series; the scatter carries the `name`, so\n", + "legend entries get dot swatches and the unnamed lines stay out of the legend.\n", + "The dotted frontier rules are `xy.hline` with `style={\"dash\": ...}`. Rule labels\n", + "have no anchor control (they pin start-anchored at the plot's right edge and run\n", + "outward, clipping at the figure edge), so the frontier names are separate\n", + "end-anchored `xy.text` annotations pinned just inside the right edge, lifted above\n", + "each line with `dy`. The two single-model pins are `xy.marker`\n", + "(`symbol=\"diamond\"` / `\"square\"`) with side text.\n", + "\n", + "**Known gaps vs the reference (not worked around):** xy's legend `loc` only\n", + "anchors *inside* the plot rect — there is no outside-the-axes placement, so the\n", + "legend sits at `upper center` in the plot instead of above it; and there is no\n", + "image annotation, so the brand logo in the top-right corner is dropped.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b2f259d", + "metadata": {}, + "outputs": [], + "source": [ + "K = 1_000.0\n", + "\n", + "series = [\n", + " (\"GPT-5.6 Sol\", \"#eaf1fe\", [(30, 28.0), (62, 52.0), (95, 63.0), (105, 70.5), (118, 74.0)]),\n", + " (\"GPT-5.6 Terra\", \"#cedffe\", [(32, 22.0), (105, 41.5), (135, 46.0), (162, 53.0)]),\n", + " (\"GPT-5.6 Luna\", \"#a3befa\", [(35, 17.0), (80, 23.5), (108, 30.5)]),\n", + " (\"GPT-5.5\", \"#f6c3cb\", [(55, 28.0), (88, 40.3), (98, 47.8), (120, 48.0)]),\n", + " (\"GPT-5.4\", \"#ef6fa8\", [(85, 26.5), (125, 30.8), (135, 31.2), (160, 33.5), (200, 38.0)]),\n", + "]\n", + "\n", + "marks = []\n", + "for name, color, pts in series:\n", + " xs = [p[0] * K for p in pts]\n", + " ys = [p[1] for p in pts]\n", + " marks.append(xy.line(x=xs, y=ys, color=color, width=3))\n", + " marks.append(xy.scatter(x=xs, y=ys, color=color, size=10, opacity=1.0, name=name))\n", + "\n", + "# Rule labels have no anchor control, so the frontier names are end-anchored\n", + "# text annotations pinned just inside the plot's right edge, lifted clear of\n", + "# each dotted line with dy.\n", + "ref_label_style = {\"font_size\": \"17px\", \"font_weight\": \"700\"}\n", + "pin_style = {\"vertical_align\": \"middle\", \"font_size\": \"17px\", \"font_weight\": \"700\"}\n", + "\n", + "chart = xy.scatter_chart(\n", + " *marks,\n", + " xy.hline(78.2, color=\"#f6e8cd\", width=3, style={\"dash\": \"2.5,9\"}),\n", + " xy.hline(40.3, color=\"#f2a07b\", width=3, style={\"dash\": \"2.5,9\"}),\n", + " xy.text(\n", + " 548 * K, 78.2, \"Mythos 5\", dy=-14, anchor=\"end\", color=\"#f6e8cd\", style=ref_label_style\n", + " ),\n", + " xy.text(\n", + " 548 * K, 40.3, \"Opus 4.8\", dy=-14, anchor=\"end\", color=\"#f2a07b\", style=ref_label_style\n", + " ),\n", + " xy.marker(\n", + " 310 * K,\n", + " 74.0,\n", + " text=\"Mythos Preview\",\n", + " color=\"#f6e8cd\",\n", + " symbol=\"diamond\",\n", + " size=14,\n", + " stroke_width=0,\n", + " dx=18,\n", + " dy=0,\n", + " style=pin_style,\n", + " ),\n", + " xy.marker(\n", + " 225 * K,\n", + " 28.5,\n", + " text=\"Opus 4.7\",\n", + " color=\"#ef8a51\",\n", + " symbol=\"square\",\n", + " size=13,\n", + " stroke_width=0,\n", + " dx=18,\n", + " dy=0,\n", + " style=pin_style,\n", + " ),\n", + " xy.x_axis(\n", + " label=\"Output tokens\",\n", + " domain=(0, 560 * K),\n", + " tick_values=[100 * K, 200 * K, 300 * K, 400 * K, 500 * K],\n", + " tick_labels=[\"100K\", \"200K\", \"300K\", \"400K\", \"500K\"],\n", + " style={\"tick_length\": 6},\n", + " ),\n", + " xy.y_axis(\n", + " label=\"Cap percent\",\n", + " domain=(12, 84),\n", + " tick_values=[20, 40, 60, 80],\n", + " tick_labels=[\"20%\", \"40%\", \"60%\", \"80%\"],\n", + " style={\"tick_length\": 6},\n", + " ),\n", + " # loc anchors inside the plot rect; the reference's above-the-axes legend\n", + " # placement doesn't exist in xy yet (see the markdown cell above).\n", + " xy.legend(loc=\"upper center\", ncols=3),\n", + " xy.theme(\n", + " background=\"#000000\",\n", + " text_color=\"#ffffff\",\n", + " grid_color=\"transparent\",\n", + " axis_color=\"#f0f1f2\",\n", + " ),\n", + " styles={\n", + " \"title\": {\n", + " \"text_align\": \"left\",\n", + " \"padding_left\": \"60px\",\n", + " \"padding_top\": \"40px\",\n", + " \"font_size\": \"24px\",\n", + " \"font_weight\": \"700\",\n", + " },\n", + " \"tick_label\": {\"font_size\": \"16px\", \"font_family\": \"ui-monospace, monospace\"},\n", + " \"axis_title\": {\"font_size\": \"17px\"},\n", + " \"legend\": {\"font_size\": \"17px\"},\n", + " },\n", + " title=\"ExploitBench\",\n", + " width=1160,\n", + " height=1150,\n", + " padding=[100, 60, 110, 110],\n", + ")\n", + "chart" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "xy (3.12.13.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/demo.ipynb b/examples/demo.ipynb index 8f43c2ae..18e15b1e 100644 --- a/examples/demo.ipynb +++ b/examples/demo.ipynb @@ -461,12 +461,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "id": "7efdda61-c484-454c-8247-489567bb8888", "metadata": {}, - "outputs": [], - "source": [] + "source": "## Reference recreation — TerminalBench 2.1 (dark benchmark bars)\n\nA benchmark-leaderboard bar chart: black card, per-bar palette colors, rounded\nstroked bars, value labels above each bar, a percent y-axis, and slanted\ncategory labels hanging from tick marks. Per-bar colors come from composing\n**one `xy.bar` mark per category** (categories merge across marks in\nfirst-appearance order); the value labels are `xy.text` annotations anchored at\neach category; `tick_label_anchor=\"end\"` pins each rotated name's trailing edge\nat its tick so the labels hang below the axis; the dark card is\n`xy.theme(background=\"#000000\")` — the figure background (matplotlib's\n`figure.facecolor`), painting the whole card including margins and labels.\n`plot_background` would tint only the plot rect (`axes.facecolor`)." }, { "cell_type": "code", @@ -474,7 +472,7 @@ "id": "65c3a9b6-8b8a-483d-b158-b9699ab63188", "metadata": {}, "outputs": [], - "source": [] + "source": "models = [\n \"GPT-5.6 Sol Ultra\",\n \"GPT-5.6 Sol\",\n \"Claude Mythos 5\",\n \"GPT-5.6 Terra\",\n \"Claude Fable 5\",\n \"GPT-5.5\",\n \"GPT-5.6 Luna\",\n \"Claude Opus 4.8\",\n \"Gemini 3.1 Pro Preview\",\n]\nscores = [91.9, 88.8, 88.0, 84.3, 84.3, 83.4, 82.5, 78.9, 70.7]\nbar_colors = [\n \"#eaf1fe\", # blue-1\n \"#eaf1fe\", # blue-1\n \"#47484f\", # gray-5\n \"#cedffe\", # blue-2\n \"#47484f\", # gray-5\n \"#5477c4\", # blue-4\n \"#a3befa\", # blue-3\n \"#47484f\", # gray-5\n \"#47484f\", # gray-5\n]\n\nxy.bar_chart(\n *[\n xy.bar(\n x=[m], y=[s], color=c, width=0.85, corner_radius=8, stroke=\"#f0f1f2\", stroke_width=1.5\n )\n for m, s, c in zip(models, scores, bar_colors, strict=True)\n ],\n *[\n xy.text(\n m,\n s,\n f\"{s:.1f}%\",\n dx=0,\n dy=-8,\n anchor=\"middle\",\n color=\"#ffffff\",\n style={\"font_size\": \"15px\", \"font_weight\": \"500\"},\n )\n for m, s in zip(models, scores, strict=True)\n ],\n # tick_label_anchor=\"end\" pins each slanted name's trailing edge at its\n # tick (mpl ha='right'), so labels hang below the axis like the reference.\n xy.x_axis(label=None, tick_label_angle=-30, tick_label_anchor=\"end\", style={\"tick_length\": 6}),\n xy.y_axis(\n label=\"Score\",\n domain=(0, 100),\n style={\"tick_length\": 6},\n tick_values=[0, 25, 50, 75, 100],\n tick_labels=[\"0%\", \"25%\", \"50%\", \"75%\", \"100%\"],\n ),\n xy.legend(show=False),\n # background= is the figure background (mpl figure.facecolor): it paints\n # the whole card — margins, title, tick labels — and shows through the\n # plot rect, so no separate plot_background is needed for a flat card.\n xy.theme(\n background=\"#000000\",\n text_color=\"#ffffff\",\n grid_color=\"transparent\",\n axis_color=\"#f0f1f2\",\n ),\n styles={\n \"title\": {\n \"text_align\": \"left\",\n \"padding_left\": \"40px\",\n \"padding_top\": \"18px\",\n \"font_size\": \"22px\",\n \"font_weight\": \"700\",\n },\n \"tick_label\": {\"font_size\": \"13px\"},\n \"axis_title\": {\"font_size\": \"15px\"},\n },\n # width=\"100%\" fills the notebook output row - VS Code paints a white\n # backdrop behind widget outputs, so a fixed-width card would sit on white.\n title=\"TerminalBench 2.1\",\n width=\"100%\",\n height=680,\n padding=[90, 40, 160, 90],\n)" } ], "metadata": { @@ -498,4 +496,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/examples/exploitbench.png b/examples/exploitbench.png new file mode 100644 index 0000000000000000000000000000000000000000..de5b3bb251327f01b520fb6797bf67f186320957 GIT binary patch literal 136039 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%0A^(nMGw(8F0luk;n&<_EPA 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 fqB>*rR46vyCbdkUref08fiH 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

RkMLB1z`@P`!eQe@5kMBv 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ℑ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 bo1b`Yw%_jAx^;0Fxj<86b0da<^XdNpq3xN2 literal 0 HcmV?d00001 diff --git a/js/src/20_theme.js b/js/src/20_theme.js index c8aece40..38b2a514 100644 --- a/js/src/20_theme.js +++ b/js/src/20_theme.js @@ -114,6 +114,14 @@ const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="canvas"]:focus-visible,.xy [data-xy-slot="modebar_button"]:focus-visible){outline:2px solid var(--chart-focus,#2563eb);outline-offset:2px} @media (prefers-reduced-motion:reduce){:where(.xy [data-xy-slot="modebar"]){transition-duration:0s!important}} @media (forced-colors:active){:where(.xy [data-xy-slot="modebar"],.xy [data-xy-slot="tooltip"]){border:1px solid CanvasText}:where(.xy [data-xy-slot="modebar_button"].xy-active){outline:2px solid Highlight}:where(.xy [data-xy-slot="canvas"]:focus){outline:2px solid Highlight}} +/* VS Code's Jupyter webview wraps ipywidget outputs in an opaque white card so + widgets that assume a light page stay legible on dark editor themes — the + same role as matplotlib's always-opaque white figure patch, so unthemed + charts keep it. A chart that brings its own background (theme(background=), + marked data-xy-own-bg) would sit in a white frame instead, so only those + outputs drop the backdrop. !important because the host rule this overrides + carries higher specificity. */ +.cell-output-ipywidget-background:has(.xy[data-xy-own-bg]){background:transparent!important} `; // Inject XY_CHROME_CSS once per DOM root (document head or shadow root), so diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index 8081e1ce..6a87b28f 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -174,7 +174,13 @@ class ChartView { this._layout(); this._buildDom(el); + // getComputedStyle yields nothing on a detached element, so a chart + // constructed before its output node lands in the document (notebook + // webviews attach asynchronously) would freeze on fallback theme colors — + // gray grid over a themed card. Track staleness and heal on the first + // frame (or visibility change) after connection. this.theme = readTheme(this.root); + this._themeStale = !this.root.isConnected; // Retained for GL context restore: the payload is screen-bounded (§29) so // keeping it is cheap, and every GPU object is rebuildable from // spec + payload by design (§18/§27). @@ -758,6 +764,7 @@ class ChartView { if (this._ctxVisible) { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); + if (this._healStaleTheme()) this.draw(); } }, { rootMargin: "25% 0px 25% 0px" }, @@ -814,6 +821,10 @@ class ChartView { (this.fluidH ? "min-height:120px;" : "") + // parent without a height -> visible floor "font:12px system-ui,sans-serif;user-select:none;"; this._applySlot(root, "root"); + // A chart that brings its own backdrop (theme(background=) → inline root + // background) marks itself so host-page overrides — VS Code's white + // ipywidget card — can be scoped to charts that don't need it. + if (root.style.background || root.style.backgroundColor) root.dataset.xyOwnBg = ""; el.appendChild(root); this.root = root; // Visual chrome defaults live in one zero-specificity stylesheet so user @@ -2112,6 +2123,7 @@ class ChartView { _drawNow() { if (this._destroyed || !this.gl || this._glLost) return; + this._healStaleTheme(); const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -2807,6 +2819,18 @@ class ChartView { return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; } + _axisTickLabelAnchor(axis) { + const raw = axis && axis.tick_label_anchor !== undefined + ? axis.tick_label_anchor + : this._axisStyleValue(axis, "tick_label_anchor"); + if (raw == null) return null; + const value = String(raw).toLowerCase(); + if (value === "start" || value === "left") return "start"; + if (value === "end" || value === "right") return "end"; + if (value === "center" || value === "middle") return "center"; + return null; // unset/unknown: the caller picks its dimension's default + } + _axisTickLabelAngle(axis) { const raw = axis && axis.tick_label_angle !== undefined ? axis.tick_label_angle @@ -2836,7 +2860,7 @@ class ChartView { : Math.abs(Math.cos(angle)) * size.w + Math.abs(Math.sin(angle)) * size.h; } - _tickLabelsCollide(labels, dim, fontSize, minGap) { + _tickLabelsCollide(labels, dim, fontSize, minGap, anchor = "center") { const rows = new Map(); for (const label of labels) { const row = Number(label.row || 0); @@ -2845,6 +2869,25 @@ class ChartView { } for (const rowLabels of rows.values()) { rowLabels.sort((a, b) => a.pos - b.pos); + if (dim === "x" && anchor !== "center") { + // Edge-anchored labels all run the same direction from their tick. + // Rotated ones are parallel lines: they clear each other when the + // perpendicular gap between adjacent anchors exceeds the line height, + // regardless of how far their horizontal bounding boxes overlap. + for (let i = 1; i < rowLabels.length; i++) { + const prev = rowLabels[i - 1]; + const label = rowLabels[i]; + const spacing = label.pos - prev.pos; + const angle = Math.abs(Number(label.angle || 0)) * Math.PI / 180; + if (angle) { + if (spacing * Math.sin(angle) < fontSize * 1.2 + minGap) return true; + } else { + const lead = anchor === "end" ? label : prev; + if (spacing < this._estimateTickLabel(lead.text, fontSize).w + minGap) return true; + } + } + continue; + } let lastEnd = -Infinity; for (const label of rowLabels) { const extent = this._tickLabelExtent(label, dim, fontSize); @@ -2857,11 +2900,11 @@ class ChartView { return false; } - _downsampleTickLabels(labels, dim, fontSize, minGap) { + _downsampleTickLabels(labels, dim, fontSize, minGap, anchor = "center") { if (labels.length <= 1) return labels; for (let stride = 2; stride <= labels.length; stride++) { const out = labels.filter((_, i) => i % stride === 0); - if (!this._tickLabelsCollide(out, dim, fontSize, minGap)) return out; + if (!this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) return out; } return labels.slice(0, 1); } @@ -2873,6 +2916,10 @@ class ChartView { this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)), ); const minGap = this._axisTickLabelMinGap(axis, dim); + // y collision keeps the centered extent model: every label on an axis + // shares one anchor+angle, so an anchored y layout shifts all boxes by + // the same offset and pairwise gaps are unchanged. + const anchor = dim === "x" ? (this._axisTickLabelAnchor(axis) ?? "center") : "center"; const explicitAngle = this._axisTickLabelAngle(axis); const baseAngle = explicitAngle === null ? 0 : explicitAngle; const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); @@ -2880,7 +2927,7 @@ class ChartView { if (strategy === "none") return []; // hide every tick label (sparklines) if (strategy === "off") return []; // labels only; grid/baselines stay (mpl shared axes) if (strategy === "auto") { - if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap)) return withBase; + if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap, anchor)) return withBase; if (dim === "x" && axis.kind === "category" && labels.length <= 16) strategy = "rotate"; else if (dim === "x" && labels.length <= 24) strategy = "stagger"; else strategy = "hide"; @@ -2894,8 +2941,8 @@ class ChartView { out = labels.map((label, i) => ({ ...label, angle: baseAngle, row: i % 2 })); } - if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap)) { - out = this._downsampleTickLabels(out, dim, fontSize, minGap); + if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { + out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } @@ -3129,19 +3176,26 @@ class ChartView { const text = this._axisTickText(xAxis, v, xt.step); xLabelCandidates.push({ pos: px, text }); } + const tickLabelSize = this._axisStyleNumber( + xAxis, + "tick_label_size", + this._axisStyleNumber(xAxis, "tick_size", 11), + ); + // The anchored edge is the transform origin, so a rotated label pivots + // about the point pinned at the tick (mpl `ha` + anchored rotation) + // instead of seesawing its trailing half into the plot. + const xAnchor = this._axisTickLabelAnchor(xAxis) ?? "center"; + const xShift = xAnchor === "end" ? "-100%" : xAnchor === "start" ? "0%" : "-50%"; + const xOrigin = + `${xAnchor === "end" ? "right" : xAnchor === "start" ? "left" : "center"} ` + + `${xAxis.side === "top" ? "bottom" : "top"}`; for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { - const tickLabelSize = this._axisStyleNumber( - xAxis, - "tick_label_size", - this._axisStyleNumber(xAxis, "tick_size", 11), - ); const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; - const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`; - const origin = xAxis.side === "top" ? "bottom center" : "top center"; + const transform = `translateX(${xShift}) rotate(${Number(item.angle || 0)}deg)`; label( item.text, - `left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${origin};`, + `left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${xOrigin};`, xAxis, ); } @@ -3152,12 +3206,21 @@ class ChartView { const text = this._axisTickText(yAxis, v, yt.step); yLabelCandidates.push({ pos: py, text }); } + // Same anchored-pivot scheme as the x labels above: the pinned edge is + // the transform origin, so a rotated label pivots about the point at the + // tick. Unset defaults to the tick-side edge — mpl `ha`: "end" left of + // the plot, "start" right of it — reproducing the classic layout. + const yLabelCss = (axis, onRight, item) => { + const pin = onRight ? p.x + p.w + 8 : p.x - 8; + const anchor = this._axisTickLabelAnchor(axis) ?? (onRight ? "start" : "end"); + const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; + const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; + return `left:${pin}px;top:${item.pos}px;` + + `transform:translate(${shift},-50%) rotate(${Number(item.angle || 0)}deg);` + + `transform-origin:${originX} center;`; + }; for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { - const angle = Number(item.angle || 0); - const css = yAxis.side === "right" - ? `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;` - : `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;`; - label(item.text, css, yAxis); + label(item.text, yLabelCss(yAxis, yAxis.side === "right", item), yAxis); } for (const axis of Object.values(this.axes)) { if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; @@ -3170,11 +3233,7 @@ class ChartView { labelCandidates.push({ pos: py, text }); } for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { - const angle = Number(item.angle || 0); - const css = axis.side === "left" - ? `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;` - : `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;`; - label(item.text, css, axis); + label(item.text, yLabelCss(axis, axis.side !== "left", item), axis); } if (axis.label) { const fallbackCss = axis.side === "left" @@ -3548,17 +3607,33 @@ class ChartView { return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); } - refreshTheme() { - if (this._destroyed) return; + _applyTheme() { this.theme = readTheme(this.root); + // A theme read on a detached root saw no computed styles; stay flagged + // stale until the element connects and _healStaleTheme re-reads. + this._themeStale = !this.root.isConnected; for (const g of this.gpuTraces) { // Re-resolve CSS-expressed constant colors (§36 live re-resolution); // each mark kind knows where its constant color lives in the spec. markOf(g.trace.kind).refreshColor?.(this, g); } + } + + refreshTheme() { + if (this._destroyed) return; + this._applyTheme(); this.draw(); } + // Once a stale-themed root is connected, re-read tokens and re-resolve mark + // colors in place. Returns true when a heal happened (callers outside a + // frame should redraw). + _healStaleTheme() { + if (!this._themeStale || !this.root.isConnected) return false; + this._applyTheme(); + return true; + } + destroy() { if (this._destroyed) return; this._destroyed = true; diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 97a5f16d..f7979990 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -145,6 +145,7 @@ def set_axis( tick_labels: Optional[Any] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[str] = None, + tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, style: Optional[dict[str, Any]] = None, @@ -193,6 +194,9 @@ def set_axis( "tick_label_strategy": self._axis_tick_label_strategy( tick_label_strategy, f"{axis_id} axis tick_label_strategy" ), + "tick_label_anchor": self._axis_tick_label_anchor( + tick_label_anchor, f"{axis_id} axis tick_label_anchor" + ), "tick_label_min_gap": None if tick_label_min_gap is None else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"), @@ -439,6 +443,7 @@ def _as_1d_float(values: Any, label: str) -> np.ndarray: _optional_finite_scalar = staticmethod(_validate.optional_finite_scalar) _optional_positive_int = staticmethod(_validate.optional_positive_int) _axis_tick_label_strategy = staticmethod(_validate.axis_tick_label_strategy) + _axis_tick_label_anchor = staticmethod(_validate.axis_tick_label_anchor) _nonnegative_scalar = staticmethod(_validate.nonnegative_scalar) _opacity = staticmethod(_validate.opacity) _padding = staticmethod(_validate.plot_padding) @@ -924,6 +929,9 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any tick_label_strategy = self._axis_tick_label_strategy( opts.get("tick_label_strategy"), f"{axis_id} axis tick_label_strategy" ) + tick_label_anchor = self._axis_tick_label_anchor( + opts.get("tick_label_anchor"), f"{axis_id} axis tick_label_anchor" + ) tick_label_min_gap = ( None if opts.get("tick_label_min_gap") is None @@ -955,6 +963,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any spec["tick_label_angle"] = tick_label_angle if tick_label_strategy is not None: spec["tick_label_strategy"] = tick_label_strategy + if tick_label_anchor is not None: + spec["tick_label_anchor"] = tick_label_anchor if tick_label_min_gap is not None: spec["tick_label_min_gap"] = tick_label_min_gap if self._axis_scale(axis_id) == "log": diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 2bb4e4cb..f9219031 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -31,9 +31,12 @@ _corner_radii, _css, _lut, + _px_size, _resolve_static_css_vars, _Scale, + _solid_paint, _step_arrays, + _tick_label_anchor, _tick_text, axis_ticks, hexbin_ring, @@ -115,6 +118,18 @@ def _rgba(css: Any, fallback: str, opacity: float = 1.0) -> tuple[int, int, int, return _parse_color(_css(css, fallback), opacity) +def _solid_color(css: Any) -> Optional[tuple[int, int, int, int]]: + """A parseable solid CSS color, or None when unset/unpaintable (var(), + gradients) — for background fills that must be skipped rather than + fallback-painted. One policy with the SVG exporter (`_solid_paint`).""" + s = _solid_paint(css) + return None if s is None else _parse_color(s) + + +# cmd.text anchor codes (must match src/raster.rs): start/center/end of string. +_TEXT_ANCHOR_CODES = {"start": 0, "center": 1, "end": 2} + + def _fill_opacity(style: dict[str, Any], default: float = 1.0) -> float: return float(style.get("opacity", default)) * float(style.get("fill_opacity", 1.0)) @@ -525,22 +540,42 @@ def render_raster( dom_style = (spec.get("dom") or {}).get("style") or {} + # Figure patch (mpl figure.facecolor): `theme(background=)` lands on the + # root element's CSS background, painted over the whole canvas so the + # margins match the browser. Gradients stay browser-only (skipped). + figure_background = _solid_color(dom_style.get("background")) + # The fused PNG path initializes its native canvas white, avoiding a second - # full-frame memory pass. Raw RGBA callers still receive an explicit fill. - if not fast_png: + # full-frame memory pass. Raw RGBA callers still receive an explicit fill — + # skipped when an opaque figure background would fully cover it anyway + # (a translucent one keeps the white underlay to composite over, matching + # the browser's white host page). + if not fast_png and (figure_background is None or figure_background[3] < 255): cmd.fill( _rect_pts(0, 0, width, height), _parse_color(spec.get("canvas_background", "#ffffff")), ) + if figure_background is not None: + cmd.fill(_rect_pts(0, 0, width, height), figure_background) # Static exports honor the same axes background token as HTML/SVG. This # is deliberately a plot-rect fill rather than a canvas fill: the latter - # is the Figure patch and is composed by pyplot's grid exporter. - plot_background = _parse_color(_css(dom_style.get("--chart-bg"), "#ffffff")) - cmd.fill( - _rect_pts(plot["x"], plot["y"], plot["x"] + plot["w"], plot["y"] + plot["h"]), - plot_background, - ) + # is the Figure patch, composed above (or by pyplot's grid exporter). An + # unset token keeps the plot rect transparent when a figure background is + # present — matching the browser, where the root shows through — and + # falls back to the classic white fill otherwise. + plot_css = _css(dom_style.get("--chart-bg"), "") + if plot_css: + plot_background = _parse_color(plot_css) + elif figure_background is None: + plot_background = _parse_color("#ffffff") + else: + plot_background = None + if plot_background is not None: + cmd.fill( + _rect_pts(plot["x"], plot["y"], plot["x"] + plot["w"], plot["y"] + plot["h"]), + plot_background, + ) xt, xlab, xstep = axis_ticks(xa, plot["w"], True) yt, ylab, ystep = axis_ticks(ya, plot["h"], False) @@ -691,12 +726,13 @@ def tick_span(style): x_tick_c = _parse_color( _css(xstyle.get("tick_label_color", xstyle.get("tick_color")), default_text) ) + x_anchor = _TEXT_ANCHOR_CODES[_tick_label_anchor(xa, xstyle, "center")] for v in xlab: label_y = py0 - 7 if xa.get("side") == "top" else py1 + 15 cmd.text( float(sx(v)), label_y, - 1, + x_anchor, float(xstyle.get("tick_label_size", xstyle.get("tick_size", 11))), x_tick_c, _tick_text(xa, v, xstep), @@ -705,11 +741,12 @@ def tick_span(style): y_tick_c = _parse_color( _css(ystyle.get("tick_label_color", ystyle.get("tick_color")), default_text) ) + y_anchor = _TEXT_ANCHOR_CODES[_tick_label_anchor(ya, ystyle, "end")] for v in ylab: cmd.text( px0 - 8, float(sy(v)) + 4, - 2, + y_anchor, float(ystyle.get("tick_label_size", ystyle.get("tick_size", 11))), y_tick_c, _tick_text(ya, v, ystep), @@ -855,7 +892,7 @@ def _emit_annotations(cmd, annotations, sx, sy, plot, width, height, *, phase="m if ann.get("kind") in ("text", "callout") and ann.get("text"): x, y = _annotation_point(ann, style, sx, sy, plot, width, height) anchor = {"start": 0, "middle": 1, "end": 2}.get(ann.get("anchor"), 0) - font_size = float(style.get("font_size", 11)) + font_size = _px_size(style.get("font_size"), 11.0) lines = str(ann["text"]).splitlines() or [""] line_height = font_size * 1.2 # A callout's `color` paints its arrow; the label prefers its own. diff --git a/python/xy/_svg.py b/python/xy/_svg.py index b18dd2c8..59aaecce 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -592,6 +592,45 @@ def _css(c: Any, fallback: str) -> str: return s +_TEXT_ANCHORS = {"start": "start", "center": "middle", "end": "end"} + + +def _tick_label_anchor(axis: dict[str, Any], style: dict[str, Any], default: str) -> str: + """Canonical tick-label anchor (``start``/``center``/``end``) from the + axis spec or its style — validators normalize the mpl aliases upstream — + with ``default`` (the classic layout) when unset.""" + raw = axis.get("tick_label_anchor") or style.get("tick_label_anchor") + return raw if raw in ("start", "center", "end") else default + + +def _px_size(value: Any, default: float) -> float: + """Tolerant CSS px length — `15` or `"15px"` — matching the browser, where + annotation styles land as CSS declarations; `default` on anything else.""" + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip().removesuffix("px")) + except ValueError: + return default + return default + + +def _solid_paint(css: Any) -> Optional[str]: + """A parseable solid CSS color string, or None when unset/unpaintable + (var(), gradients) — for background rects that must be omitted rather + than fallback-painted.""" + from . import kernels + + s = _css(css, "") + if not s: + return None + _status, rgba = kernels.css_check(kernels.CSS_COLOR, s) + if rgba is None: + return None + return s + + _CSS_VAR_RE = re.compile( r"^var\(\s*(--[A-Za-z_][A-Za-z0-9_-]*)\s*(?:,\s*(.+))?\)$", re.DOTALL | re.IGNORECASE, @@ -1035,6 +1074,10 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f'stroke-width="{_num(float(ystyle.get("grid_width", 1)))}"' f"{_axis_grid_attrs(ystyle)}/>" ) + # Anchored tick labels rotate about the tick point (the rotate() pivot + # below), so anchor and rotation compose — matching the browser client. + x_anchor = _TEXT_ANCHORS[_tick_label_anchor(xa, xstyle, "center")] + y_anchor = _TEXT_ANCHORS[_tick_label_anchor(ya, ystyle, "end")] if not hide_x_labels: for v in xlab: tick_y = plot["y"] - 7 if xa.get("side") == "top" else plot["y"] + plot["h"] + 16 @@ -1042,7 +1085,7 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f' tuple[list[float], list f' tuple[float, float, float]: f'width="{_num(plot["w"])}" height="{_num(plot["h"])}"/>' ) defs = f"{''.join(svg.defs)}" if svg.defs else "" + # Figure patch + plot-rect backgrounds, mirroring the browser: the root + # element's CSS `background` (theme(background=)) behind everything, then + # the --chart-bg token over the plot rect only. Solid colors only — + # gradients stay browser-only, and an unset token stays transparent. + backgrounds = "" + figure_background = _solid_paint(dom_style.get("background")) + if figure_background is not None: + backgrounds += ( + f'' + ) + plot_paint = _solid_paint(dom_style.get("--chart-bg")) + if plot_paint is not None: + backgrounds += ( + f'' + ) return ( f'' f"{defs}" + f"{backgrounds}" f"{''.join(grid)}" f'{"".join(marks)}' f"{baselines}" @@ -1362,7 +1422,7 @@ def _annotation_svg(annotations, sx, sy, plot, width, height): anchor = {"start": "start", "middle": "middle", "end": "end"}.get( ann.get("anchor"), "start" ) - font_size = float(style.get("font_size", 11)) + font_size = _px_size(style.get("font_size"), 11.0) lines = str(ann["text"]).splitlines() or [""] line_height = font_size * 1.2 rotation = float(style.get("rotation", 0.0)) % 360.0 diff --git a/python/xy/_validate.py b/python/xy/_validate.py index bf59280e..bfee7320 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -21,6 +21,15 @@ import numpy as np _TICK_LABEL_STRATEGIES = frozenset({"auto", "hide", "rotate", "stagger", "none", "off"}) +# Canonical anchors plus the matplotlib `ha` vocabulary the pyplot shim emits. +_TICK_LABEL_ANCHORS = { + "start": "start", + "center": "center", + "end": "end", + "left": "start", + "middle": "center", + "right": "end", +} _LABEL_POSITIONS = frozenset( {"start", "center", "end", "inside_start", "inside_center", "inside_end"} ) @@ -145,6 +154,20 @@ def axis_tick_label_strategy(value: Any, label: str) -> Optional[str]: return normalized +def axis_tick_label_anchor(value: Any, label: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{label} must be a string or None") + normalized = _TICK_LABEL_ANCHORS.get(value.lower()) + if normalized is None: + raise ValueError( + f"{label} must be one of ['center', 'end', 'start'] " + "(or the aliases 'left', 'middle', 'right')" + ) + return normalized + + def string_mapping(value: dict[str, Any], label: str) -> dict[str, str]: if not isinstance(value, dict): raise ValueError(f"{label} must be a dict[str, str]") diff --git a/python/xy/components.py b/python/xy/components.py index fa1b2a07..541cfe9c 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -51,6 +51,7 @@ _axis_id = _validate.axis_id _optional_positive_int = _validate.optional_positive_int _axis_tick_label_strategy = _validate.axis_tick_label_strategy +_axis_tick_label_anchor = _validate.axis_tick_label_anchor _axis_label_position = _validate.axis_label_position _optional_finite_number = _validate.optional_finite_scalar _optional_nonnegative_number = _validate.optional_nonnegative_scalar @@ -185,6 +186,7 @@ class Axis(Component): tick_labels: Optional[list[str]] = None tick_label_angle: Optional[float] = None tick_label_strategy: Optional[AxisTickLabelStrategy] = None + tick_label_anchor: Optional[str] = None tick_label_min_gap: Optional[float] = None side: Optional[str] = None style: dict[str, StyleValue] = field(default_factory=dict) @@ -1899,6 +1901,7 @@ def x_axis( tick_labels: Optional[Any] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[AxisTickLabelStrategy] = None, + tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, style: Optional[dict[str, StyleValue]] = None, @@ -1920,6 +1923,12 @@ def x_axis( tick_labels: Labels corresponding to explicit tick positions. tick_label_angle: Tick-label rotation in degrees. tick_label_strategy: Collision-handling strategy for tick labels. + tick_label_anchor: Which edge of a tick label pins to its tick — + ``"start"``, ``"center"`` (default), or ``"end"`` (matplotlib's + ``ha`` values ``"left"``/``"right"`` are accepted as aliases). + With ``tick_label_angle``, the label rotates about the pinned + edge, so an end-anchored slanted label hangs entirely below a + bottom axis instead of seesawing around its midpoint. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. style: Axis style overrides. @@ -1947,6 +1956,7 @@ def x_axis( tick_label_strategy=_axis_tick_label_strategy( tick_label_strategy, "x_axis tick_label_strategy" ), + tick_label_anchor=_axis_tick_label_anchor(tick_label_anchor, "x_axis tick_label_anchor"), tick_label_min_gap=_optional_nonnegative_number( tick_label_min_gap, "x_axis tick_label_min_gap" ), @@ -1971,6 +1981,7 @@ def y_axis( tick_labels: Optional[Any] = None, tick_label_angle: Optional[float] = None, tick_label_strategy: Optional[AxisTickLabelStrategy] = None, + tick_label_anchor: Optional[str] = None, tick_label_min_gap: Optional[float] = None, side: Optional[str] = None, style: Optional[dict[str, StyleValue]] = None, @@ -1992,6 +2003,12 @@ def y_axis( tick_labels: Labels corresponding to explicit tick positions. tick_label_angle: Tick-label rotation in degrees. tick_label_strategy: Collision-handling strategy for tick labels. + tick_label_anchor: Which edge of a tick label pins to its tick — + ``"start"``, ``"center"``, or ``"end"`` (matplotlib's ``ha`` + values ``"left"``/``"right"`` are accepted as aliases). Unset + defaults to the tick-side edge: ``"end"`` for a left-side axis, + ``"start"`` for a right-side one. With ``tick_label_angle``, + the label rotates about the pinned edge. tick_label_min_gap: Minimum gap between tick labels in pixels. side: Side of the plot where the axis is drawn. style: Axis style overrides. @@ -2019,6 +2036,7 @@ def y_axis( tick_label_strategy=_axis_tick_label_strategy( tick_label_strategy, "y_axis tick_label_strategy" ), + tick_label_anchor=_axis_tick_label_anchor(tick_label_anchor, "y_axis tick_label_anchor"), tick_label_min_gap=_optional_nonnegative_number( tick_label_min_gap, "y_axis tick_label_min_gap" ), @@ -2149,6 +2167,7 @@ def modebar( def theme( style: Optional[dict[str, StyleValue]] = None, *, + background: Optional[StyleValue] = None, plot_background: Optional[StyleValue] = None, grid_color: Optional[StyleValue] = None, axis_color: Optional[StyleValue] = None, @@ -2162,7 +2181,12 @@ def theme( Args: style: Base chart style overrides. - plot_background: Plot-area background color. + background: Figure background color — paints the whole chart card + including margins, title, and tick labels (matplotlib's + ``figure.facecolor``). The plot rect shows through unless + ``plot_background`` sets it separately. + plot_background: Plot-area background color — the data rect only + (matplotlib's ``axes.facecolor``). grid_color: Grid-line color. axis_color: Axis-line and tick color. text_color: Default chart text color. @@ -2175,6 +2199,7 @@ def theme( merged.update( _theme_tokens( { + "background": background, "plot_background": plot_background, "grid_color": grid_color, "axis_color": axis_color, @@ -2421,6 +2446,7 @@ def figure(self) -> Figure: tick_labels=axis.tick_labels, tick_label_angle=axis.tick_label_angle, tick_label_strategy=axis.tick_label_strategy, + tick_label_anchor=axis.tick_label_anchor, tick_label_min_gap=axis.tick_label_min_gap, side=axis.side, style=axis.style, @@ -2744,7 +2770,10 @@ def _slot_styles_dict(value: Any, label: str) -> dict[str, dict[str, StyleValue] _THEME_TOKEN_ALIASES = { "plot_background": "--chart-bg", - "background": "--chart-bg", + # `background` intentionally has no token alias: it passes through as the + # root element's CSS background, painting the whole figure — margins, + # title, tick labels — not just the plot rect (mpl figure.facecolor vs + # axes.facecolor). Static exporters honor the same key. "grid_color": "--chart-grid", "axis_color": "--chart-axis", "text_color": "--chart-text", diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 4d170b91..b7e015ae 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -269,6 +269,14 @@ const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="canvas"]:focus-visible,.xy [data-xy-slot="modebar_button"]:focus-visible){outline:2px solid var(--chart-focus,#2563eb);outline-offset:2px} @media (prefers-reduced-motion:reduce){:where(.xy [data-xy-slot="modebar"]){transition-duration:0s!important}} @media (forced-colors:active){:where(.xy [data-xy-slot="modebar"],.xy [data-xy-slot="tooltip"]){border:1px solid CanvasText}:where(.xy [data-xy-slot="modebar_button"].xy-active){outline:2px solid Highlight}:where(.xy [data-xy-slot="canvas"]:focus){outline:2px solid Highlight}} +/* VS Code's Jupyter webview wraps ipywidget outputs in an opaque white card so + widgets that assume a light page stay legible on dark editor themes — the + same role as matplotlib's always-opaque white figure patch, so unthemed + charts keep it. A chart that brings its own background (theme(background=), + marked data-xy-own-bg) would sit in a white frame instead, so only those + outputs drop the backdrop. !important because the host rule this overrides + carries higher specificity. */ +.cell-output-ipywidget-background:has(.xy[data-xy-own-bg]){background:transparent!important} `; function ensureChromeStylesheet(node) { let root = node && node.getRootNode ? node.getRootNode() : document; @@ -1815,6 +1823,7 @@ h: Math.max(this.fluidH ? 120 : 48, ch), this._layout(); this._buildDom(el); this.theme = readTheme(this.root); +this._themeStale = !this.root.isConnected; this._payload = buffer; this._glLost = false; this._ctxReleasedExt = null; @@ -2283,6 +2292,7 @@ this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; if (this._ctxVisible) { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); +if (this._healStaleTheme()) this.draw(); } }, { rootMargin: "25% 0px 25% 0px" }, @@ -2327,6 +2337,7 @@ root.style.cssText = (this.fluidH ? "min-height:120px;" : "") + "font:12px system-ui,sans-serif;user-select:none;"; this._applySlot(root, "root"); +if (root.style.background || root.style.backgroundColor) root.dataset.xyOwnBg = ""; el.appendChild(root); this.root = root; ensureChromeStylesheet(root); @@ -3452,6 +3463,7 @@ this._drawNow(); } _drawNow() { if (this._destroyed || !this.gl || this._glLost) return; +this._healStaleTheme(); const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -4090,6 +4102,17 @@ const raw = axis && axis.tick_label_strategy !== undefined const value = String(raw || "auto").replace(/-/g, "_"); return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; } +_axisTickLabelAnchor(axis) { +const raw = axis && axis.tick_label_anchor !== undefined +? axis.tick_label_anchor +: this._axisStyleValue(axis, "tick_label_anchor"); +if (raw == null) return null; +const value = String(raw).toLowerCase(); +if (value === "start" || value === "left") return "start"; +if (value === "end" || value === "right") return "end"; +if (value === "center" || value === "middle") return "center"; +return null; +} _axisTickLabelAngle(axis) { const raw = axis && axis.tick_label_angle !== undefined ? axis.tick_label_angle @@ -4115,7 +4138,7 @@ return dim === "y" ? Math.abs(Math.sin(angle)) * size.w + Math.abs(Math.cos(angle)) * size.h : Math.abs(Math.cos(angle)) * size.w + Math.abs(Math.sin(angle)) * size.h; } -_tickLabelsCollide(labels, dim, fontSize, minGap) { +_tickLabelsCollide(labels, dim, fontSize, minGap, anchor = "center") { const rows = new Map(); for (const label of labels) { const row = Number(label.row || 0); @@ -4124,6 +4147,21 @@ rows.get(row).push(label); } for (const rowLabels of rows.values()) { rowLabels.sort((a, b) => a.pos - b.pos); +if (dim === "x" && anchor !== "center") { +for (let i = 1; i < rowLabels.length; i++) { +const prev = rowLabels[i - 1]; +const label = rowLabels[i]; +const spacing = label.pos - prev.pos; +const angle = Math.abs(Number(label.angle || 0)) * Math.PI / 180; +if (angle) { +if (spacing * Math.sin(angle) < fontSize * 1.2 + minGap) return true; +} else { +const lead = anchor === "end" ? label : prev; +if (spacing < this._estimateTickLabel(lead.text, fontSize).w + minGap) return true; +} +} +continue; +} let lastEnd = -Infinity; for (const label of rowLabels) { const extent = this._tickLabelExtent(label, dim, fontSize); @@ -4135,11 +4173,11 @@ lastEnd = end; } return false; } -_downsampleTickLabels(labels, dim, fontSize, minGap) { +_downsampleTickLabels(labels, dim, fontSize, minGap, anchor = "center") { if (labels.length <= 1) return labels; for (let stride = 2; stride <= labels.length; stride++) { const out = labels.filter((_, i) => i % stride === 0); -if (!this._tickLabelsCollide(out, dim, fontSize, minGap)) return out; +if (!this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) return out; } return labels.slice(0, 1); } @@ -4150,6 +4188,7 @@ const fontSize = Math.max( this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)), ); const minGap = this._axisTickLabelMinGap(axis, dim); +const anchor = dim === "x" ? (this._axisTickLabelAnchor(axis) ?? "center") : "center"; const explicitAngle = this._axisTickLabelAngle(axis); const baseAngle = explicitAngle === null ? 0 : explicitAngle; const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); @@ -4157,7 +4196,7 @@ let strategy = this._axisTickLabelStrategy(axis); if (strategy === "none") return []; if (strategy === "off") return []; if (strategy === "auto") { -if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap)) return withBase; +if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap, anchor)) return withBase; if (dim === "x" && axis.kind === "category" && labels.length <= 16) strategy = "rotate"; else if (dim === "x" && labels.length <= 24) strategy = "stagger"; else strategy = "hide"; @@ -4169,8 +4208,8 @@ out = labels.map((label) => ({ ...label, angle, row: 0 })); } else if (strategy === "stagger" && dim === "x") { out = labels.map((label, i) => ({ ...label, angle: baseAngle, row: i % 2 })); } -if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap)) { -out = this._downsampleTickLabels(out, dim, fontSize, minGap); +if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { +out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } @@ -4374,19 +4413,23 @@ if (px < p.x - 1 || px > p.x + p.w + 1) continue; const text = this._axisTickText(xAxis, v, xt.step); xLabelCandidates.push({ pos: px, text }); } -for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { const tickLabelSize = this._axisStyleNumber( xAxis, "tick_label_size", this._axisStyleNumber(xAxis, "tick_size", 11), ); +const xAnchor = this._axisTickLabelAnchor(xAxis) ?? "center"; +const xShift = xAnchor === "end" ? "-100%" : xAnchor === "start" ? "0%" : "-50%"; +const xOrigin = +`${xAnchor === "end" ? "right" : xAnchor === "start" ? "left" : "center"} ` + +`${xAxis.side === "top" ? "bottom" : "top"}`; +for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; -const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`; -const origin = xAxis.side === "top" ? "bottom center" : "top center"; +const transform = `translateX(${xShift}) rotate(${Number(item.angle || 0)}deg)`; label( item.text, -`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${origin};`, +`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${xOrigin};`, xAxis, ); } @@ -4397,12 +4440,17 @@ if (py < p.y - 1 || py > p.y + p.h + 1) continue; const text = this._axisTickText(yAxis, v, yt.step); yLabelCandidates.push({ pos: py, text }); } +const yLabelCss = (axis, onRight, item) => { +const pin = onRight ? p.x + p.w + 8 : p.x - 8; +const anchor = this._axisTickLabelAnchor(axis) ?? (onRight ? "start" : "end"); +const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; +const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; +return `left:${pin}px;top:${item.pos}px;` + +`transform:translate(${shift},-50%) rotate(${Number(item.angle || 0)}deg);` + +`transform-origin:${originX} center;`; +}; for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { -const angle = Number(item.angle || 0); -const css = yAxis.side === "right" -? `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;` -: `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;`; -label(item.text, css, yAxis); +label(item.text, yLabelCss(yAxis, yAxis.side === "right", item), yAxis); } for (const axis of Object.values(this.axes)) { if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; @@ -4415,11 +4463,7 @@ const text = this._axisTickText(axis, v, ticks.step); labelCandidates.push({ pos: py, text }); } for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { -const angle = Number(item.angle || 0); -const css = axis.side === "left" -? `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;` -: `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;`; -label(item.text, css, axis); +label(item.text, yLabelCss(axis, axis.side !== "left", item), axis); } if (axis.label) { const fallbackCss = axis.side === "left" @@ -4715,14 +4759,23 @@ return new Uint32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); } return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); } -refreshTheme() { -if (this._destroyed) return; +_applyTheme() { this.theme = readTheme(this.root); +this._themeStale = !this.root.isConnected; for (const g of this.gpuTraces) { markOf(g.trace.kind).refreshColor?.(this, g); } +} +refreshTheme() { +if (this._destroyed) return; +this._applyTheme(); this.draw(); } +_healStaleTheme() { +if (!this._themeStale || !this.root.isConnected) return false; +this._applyTheme(); +return true; +} destroy() { if (this._destroyed) return; this._destroyed = true; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 314fdd7a..90fd42bb 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -270,6 +270,14 @@ const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="canvas"]:focus-visible,.xy [data-xy-slot="modebar_button"]:focus-visible){outline:2px solid var(--chart-focus,#2563eb);outline-offset:2px} @media (prefers-reduced-motion:reduce){:where(.xy [data-xy-slot="modebar"]){transition-duration:0s!important}} @media (forced-colors:active){:where(.xy [data-xy-slot="modebar"],.xy [data-xy-slot="tooltip"]){border:1px solid CanvasText}:where(.xy [data-xy-slot="modebar_button"].xy-active){outline:2px solid Highlight}:where(.xy [data-xy-slot="canvas"]:focus){outline:2px solid Highlight}} +/* VS Code's Jupyter webview wraps ipywidget outputs in an opaque white card so + widgets that assume a light page stay legible on dark editor themes — the + same role as matplotlib's always-opaque white figure patch, so unthemed + charts keep it. A chart that brings its own background (theme(background=), + marked data-xy-own-bg) would sit in a white frame instead, so only those + outputs drop the backdrop. !important because the host rule this overrides + carries higher specificity. */ +.cell-output-ipywidget-background:has(.xy[data-xy-own-bg]){background:transparent!important} `; function ensureChromeStylesheet(node) { let root = node && node.getRootNode ? node.getRootNode() : document; @@ -1816,6 +1824,7 @@ h: Math.max(this.fluidH ? 120 : 48, ch), this._layout(); this._buildDom(el); this.theme = readTheme(this.root); +this._themeStale = !this.root.isConnected; this._payload = buffer; this._glLost = false; this._ctxReleasedExt = null; @@ -2284,6 +2293,7 @@ this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; if (this._ctxVisible) { this._ctxSeenSeq = XY_CONTEXT_GOVERNOR.seq++; if (this._glLost && !this._destroyed) this._recoverContext(); +if (this._healStaleTheme()) this.draw(); } }, { rootMargin: "25% 0px 25% 0px" }, @@ -2328,6 +2338,7 @@ root.style.cssText = (this.fluidH ? "min-height:120px;" : "") + "font:12px system-ui,sans-serif;user-select:none;"; this._applySlot(root, "root"); +if (root.style.background || root.style.backgroundColor) root.dataset.xyOwnBg = ""; el.appendChild(root); this.root = root; ensureChromeStylesheet(root); @@ -3453,6 +3464,7 @@ this._drawNow(); } _drawNow() { if (this._destroyed || !this.gl || this._glLost) return; +this._healStaleTheme(); const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); @@ -4091,6 +4103,17 @@ const raw = axis && axis.tick_label_strategy !== undefined const value = String(raw || "auto").replace(/-/g, "_"); return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; } +_axisTickLabelAnchor(axis) { +const raw = axis && axis.tick_label_anchor !== undefined +? axis.tick_label_anchor +: this._axisStyleValue(axis, "tick_label_anchor"); +if (raw == null) return null; +const value = String(raw).toLowerCase(); +if (value === "start" || value === "left") return "start"; +if (value === "end" || value === "right") return "end"; +if (value === "center" || value === "middle") return "center"; +return null; +} _axisTickLabelAngle(axis) { const raw = axis && axis.tick_label_angle !== undefined ? axis.tick_label_angle @@ -4116,7 +4139,7 @@ return dim === "y" ? Math.abs(Math.sin(angle)) * size.w + Math.abs(Math.cos(angle)) * size.h : Math.abs(Math.cos(angle)) * size.w + Math.abs(Math.sin(angle)) * size.h; } -_tickLabelsCollide(labels, dim, fontSize, minGap) { +_tickLabelsCollide(labels, dim, fontSize, minGap, anchor = "center") { const rows = new Map(); for (const label of labels) { const row = Number(label.row || 0); @@ -4125,6 +4148,21 @@ rows.get(row).push(label); } for (const rowLabels of rows.values()) { rowLabels.sort((a, b) => a.pos - b.pos); +if (dim === "x" && anchor !== "center") { +for (let i = 1; i < rowLabels.length; i++) { +const prev = rowLabels[i - 1]; +const label = rowLabels[i]; +const spacing = label.pos - prev.pos; +const angle = Math.abs(Number(label.angle || 0)) * Math.PI / 180; +if (angle) { +if (spacing * Math.sin(angle) < fontSize * 1.2 + minGap) return true; +} else { +const lead = anchor === "end" ? label : prev; +if (spacing < this._estimateTickLabel(lead.text, fontSize).w + minGap) return true; +} +} +continue; +} let lastEnd = -Infinity; for (const label of rowLabels) { const extent = this._tickLabelExtent(label, dim, fontSize); @@ -4136,11 +4174,11 @@ lastEnd = end; } return false; } -_downsampleTickLabels(labels, dim, fontSize, minGap) { +_downsampleTickLabels(labels, dim, fontSize, minGap, anchor = "center") { if (labels.length <= 1) return labels; for (let stride = 2; stride <= labels.length; stride++) { const out = labels.filter((_, i) => i % stride === 0); -if (!this._tickLabelsCollide(out, dim, fontSize, minGap)) return out; +if (!this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) return out; } return labels.slice(0, 1); } @@ -4151,6 +4189,7 @@ const fontSize = Math.max( this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)), ); const minGap = this._axisTickLabelMinGap(axis, dim); +const anchor = dim === "x" ? (this._axisTickLabelAnchor(axis) ?? "center") : "center"; const explicitAngle = this._axisTickLabelAngle(axis); const baseAngle = explicitAngle === null ? 0 : explicitAngle; const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); @@ -4158,7 +4197,7 @@ let strategy = this._axisTickLabelStrategy(axis); if (strategy === "none") return []; if (strategy === "off") return []; if (strategy === "auto") { -if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap)) return withBase; +if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap, anchor)) return withBase; if (dim === "x" && axis.kind === "category" && labels.length <= 16) strategy = "rotate"; else if (dim === "x" && labels.length <= 24) strategy = "stagger"; else strategy = "hide"; @@ -4170,8 +4209,8 @@ out = labels.map((label) => ({ ...label, angle, row: 0 })); } else if (strategy === "stagger" && dim === "x") { out = labels.map((label, i) => ({ ...label, angle: baseAngle, row: i % 2 })); } -if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap)) { -out = this._downsampleTickLabels(out, dim, fontSize, minGap); +if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { +out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } @@ -4375,19 +4414,23 @@ if (px < p.x - 1 || px > p.x + p.w + 1) continue; const text = this._axisTickText(xAxis, v, xt.step); xLabelCandidates.push({ pos: px, text }); } -for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { const tickLabelSize = this._axisStyleNumber( xAxis, "tick_label_size", this._axisStyleNumber(xAxis, "tick_size", 11), ); +const xAnchor = this._axisTickLabelAnchor(xAxis) ?? "center"; +const xShift = xAnchor === "end" ? "-100%" : xAnchor === "start" ? "0%" : "-50%"; +const xOrigin = +`${xAnchor === "end" ? "right" : xAnchor === "start" ? "left" : "center"} ` + +`${xAxis.side === "top" ? "bottom" : "top"}`; +for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; -const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`; -const origin = xAxis.side === "top" ? "bottom center" : "top center"; +const transform = `translateX(${xShift}) rotate(${Number(item.angle || 0)}deg)`; label( item.text, -`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${origin};`, +`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${xOrigin};`, xAxis, ); } @@ -4398,12 +4441,17 @@ if (py < p.y - 1 || py > p.y + p.h + 1) continue; const text = this._axisTickText(yAxis, v, yt.step); yLabelCandidates.push({ pos: py, text }); } +const yLabelCss = (axis, onRight, item) => { +const pin = onRight ? p.x + p.w + 8 : p.x - 8; +const anchor = this._axisTickLabelAnchor(axis) ?? (onRight ? "start" : "end"); +const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; +const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; +return `left:${pin}px;top:${item.pos}px;` + +`transform:translate(${shift},-50%) rotate(${Number(item.angle || 0)}deg);` + +`transform-origin:${originX} center;`; +}; for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { -const angle = Number(item.angle || 0); -const css = yAxis.side === "right" -? `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;` -: `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;`; -label(item.text, css, yAxis); +label(item.text, yLabelCss(yAxis, yAxis.side === "right", item), yAxis); } for (const axis of Object.values(this.axes)) { if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; @@ -4416,11 +4464,7 @@ const text = this._axisTickText(axis, v, ticks.step); labelCandidates.push({ pos: py, text }); } for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { -const angle = Number(item.angle || 0); -const css = axis.side === "left" -? `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;` -: `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;`; -label(item.text, css, axis); +label(item.text, yLabelCss(axis, axis.side !== "left", item), axis); } if (axis.label) { const fallbackCss = axis.side === "left" @@ -4716,14 +4760,23 @@ return new Uint32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); } return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); } -refreshTheme() { -if (this._destroyed) return; +_applyTheme() { this.theme = readTheme(this.root); +this._themeStale = !this.root.isConnected; for (const g of this.gpuTraces) { markOf(g.trace.kind).refreshColor?.(this, g); } +} +refreshTheme() { +if (this._destroyed) return; +this._applyTheme(); this.draw(); } +_healStaleTheme() { +if (!this._themeStale || !this.root.isConnected) return false; +this._applyTheme(); +return true; +} destroy() { if (this._destroyed) return; this._destroyed = true; diff --git a/python/xy/styles.py b/python/xy/styles.py index 08620bc5..c24a1515 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -330,7 +330,7 @@ def _compile_axis_style( | _AXIS_LENGTH_PROPERTIES | _AXIS_SIZE_PROPERTIES | _AXIS_COMPAT_PROPERTIES - | {"tick_direction"} + | {"tick_direction", "tick_label_anchor"} ) out: dict[str, StyleValue] = {} sources: dict[str, str] = {} @@ -353,6 +353,8 @@ def _compile_axis_style( f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DASH_STYLES)}" ) parsed = raw + elif prop == "tick_label_anchor": + parsed = _validate.axis_tick_label_anchor(raw, f"{label}[{css_prop!r}]") else: if not isinstance(raw, str) or raw not in _AXIS_DIRECTIONS: raise ValueError(f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DIRECTIONS)}") diff --git a/tests/pyplot/test_layout_noops.py b/tests/pyplot/test_layout_noops.py index 8bcd2d80..b0f11c4a 100644 --- a/tests/pyplot/test_layout_noops.py +++ b/tests/pyplot/test_layout_noops.py @@ -56,6 +56,19 @@ def test_autofmt_xdate_rotates_x_tick_labels_on_all_axes(): assert ax._axis_props("x")["style"]["tick_label_anchor"] == "center" +def test_autofmt_xdate_survives_chart_build(): + # The default ha="right" style key must compile and reach the axis spec + # (it used to raise "unsupported property" at build time). + fig, ax = plt.subplots() + ax.plot([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]) + fig.autofmt_xdate() + + chart = ax._build_chart(640, 480) + spec, _ = chart.figure().build_payload() + assert spec["x_axis"]["tick_label_angle"] == 30 + assert spec["x_axis"]["style"]["tick_label_anchor"] == "end" + + def test_suptitle_accepts_supported_font_kwargs_and_rejects_unknown(): fig, _ = plt.subplots() fig.suptitle("title", fontsize=14, fontweight="bold", color="red", x=0.5, y=0.95) diff --git a/tests/test_components.py b/tests/test_components.py index 64b33248..c0fb0c4a 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -460,6 +460,20 @@ def test_component_style_tooltip_and_modebar_metadata_is_opt_in(): assert spec["traces"][0]["style"]["class_name"] == "xy-mark-accounts" +def test_theme_background_separates_figure_and_plot(): + # mpl parity: background= is the figure facecolor (root CSS background, + # margins included); plot_background= is the axes facecolor (--chart-bg, + # plot rect only). + chart = xy.scatter_chart( + xy.scatter(x=[1.0], y=[2.0]), + xy.theme(background="#000000", plot_background="#111111"), + ) + spec, _ = chart.figure().build_payload() + style = spec["dom"]["style"] + assert style["background"] == "#000000" + assert style["--chart-bg"] == "#111111" + + def test_declarative_core_contract_for_layered_axis_chrome_and_interaction(): legend_component = FakeReflexComponent("legend") tooltip_component = FakeReflexComponent("tooltip") @@ -1358,6 +1372,7 @@ def test_component_axis_tick_layout_controls_emit_to_payload(): tick_count=4, tick_label_angle=-35, tick_label_strategy="stagger", + tick_label_anchor="end", tick_label_min_gap=12, ), xy.y_axis(tick_count=3, tick_label_strategy="hide"), @@ -1368,14 +1383,23 @@ def test_component_axis_tick_layout_controls_emit_to_payload(): assert spec["x_axis"]["tick_count"] == 4 assert spec["x_axis"]["tick_label_angle"] == -35.0 assert spec["x_axis"]["tick_label_strategy"] == "stagger" + assert spec["x_axis"]["tick_label_anchor"] == "end" assert spec["x_axis"]["tick_label_min_gap"] == 12.0 assert spec["y_axis"]["tick_count"] == 3 assert spec["y_axis"]["tick_label_strategy"] == "hide" + assert "tick_label_anchor" not in spec["y_axis"] + + # mpl `ha` vocabulary normalizes to the canonical anchors + assert xy.x_axis(tick_label_anchor="right").tick_label_anchor == "end" + assert xy.x_axis(tick_label_anchor="left").tick_label_anchor == "start" + assert xy.x_axis(tick_label_anchor="middle").tick_label_anchor == "center" with pytest.raises(ValueError, match="tick_count"): xy.x_axis(tick_count=0) with pytest.raises(ValueError, match="tick_label_strategy"): xy.x_axis(tick_label_strategy="squish") + with pytest.raises(ValueError, match="tick_label_anchor"): + xy.x_axis(tick_label_anchor="sideways") with pytest.raises(ValueError, match="tick_label_min_gap"): xy.y_axis(tick_label_min_gap=-1) diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 506cd286..156d1e35 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -197,6 +197,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: "grid-width": "3px", "tick_label_size": "13px", "tick-direction": "inout", + "tick-label-anchor": "right", # mpl `ha` alias -> canonical "end" "label-color": "rebeccapurple", } ) @@ -204,6 +205,7 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: "grid_width": 3.0, "tick_label_size": 13.0, "tick_direction": "inout", + "tick_label_anchor": "end", "label_color": "rebeccapurple", } @@ -215,6 +217,8 @@ def test_axis_style_is_normalized_and_rejected_before_render() -> None: xy.y_axis(style={"tick_color": "definitely-not-a-color"}) with pytest.raises(ValueError, match=r"must be one of"): xy.y_axis(style={"tick_direction": "sideways"}) + with pytest.raises(ValueError, match=r"must be one of"): + xy.x_axis(style={"tick_label_anchor": "sideways"}) def test_area_outline_obeys_whole_mark_and_stroke_opacity() -> None: diff --git a/tests/test_png_export.py b/tests/test_png_export.py index b8424722..75f51508 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -168,6 +168,56 @@ def test_png_is_screen_bounded_for_large_lines() -> None: assert spec["traces"][0]["n_points"] == n # source size still recorded (§28) +def test_render_paints_figure_background() -> None: + import xy + + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.theme(background="#000000"), + width=200, + height=120, + ) + img = _raster.render_raster(*chart.figure().build_payload(), scale=1) + # Figure patch (mpl figure.facecolor) covers the margins... + assert tuple(img[0, 0]) == (0, 0, 0, 255) + # ...and shows through the plot rect when plot_background is unset + # (no white fallback fill). + assert (img[10, 50][:3] < 100).all() + + +def test_render_composites_translucent_figure_background() -> None: + import xy + + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.theme(background="rgba(0, 0, 0, 0.5)"), + width=200, + height=120, + ) + img = _raster.render_raster(*chart.figure().build_payload(), scale=1) + # A translucent figure patch composites over the white canvas fill (the + # browser's white host page) -> mid gray at full alpha, not raw rgba. + assert 100 <= int(img[0, 0][0]) <= 155 + assert img[0, 0][3] == 255 + + +def test_raster_honors_tick_label_anchor() -> None: + import xy + + def render(**axis_kwargs): + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.x_axis(**axis_kwargs), + width=200, + height=120, + ) + return _raster.render_raster(*chart.figure().build_payload(), scale=1) + + # Anchoring shifts every x tick label left of its tick; the images must + # differ only because the anchor reached the rasterizer. + assert not np.array_equal(render(), render(tick_label_anchor="end")) + + def test_render_is_non_blank_and_has_background() -> None: fig = Figure(width=200, height=120).line([0.0, 1.0], [0.0, 1.0]) img = _raster.render_raster(*fig.build_payload(), scale=1) diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 5a5a9998..3345936f 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -526,12 +526,14 @@ def test_client_axis_tick_labels_have_collision_layout() -> None: "_axisTickTarget(axisId, fallback)", "_axisTickLabelStrategy(axis)", "_axisTickLabelAngle(axis)", + "_axisTickLabelAnchor(axis)", "_axisTickLabelMinGap(axis, dim)", - "_tickLabelsCollide(labels, dim, fontSize, minGap)", - "_downsampleTickLabels(labels, dim, fontSize, minGap)", + '_tickLabelsCollide(labels, dim, fontSize, minGap, anchor = "center")', + '_downsampleTickLabels(labels, dim, fontSize, minGap, anchor = "center")', "_layoutTickLabels(axis, dim, labels)", "tick_label_strategy", "tick_label_angle", + "tick_label_anchor", "tick_label_min_gap", ) diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 016fd84e..1039345a 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -54,6 +54,54 @@ def test_every_chart_kind_exports_wellformed_svg() -> None: assert 'xmlns="http://www.w3.org/2000/svg"' in svg +def test_svg_paints_figure_and_plot_backgrounds() -> None: + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.theme(background="#000000", plot_background="#101418"), + width=300, + height=200, + ) + svg = chart.figure().to_svg() + assert '' in svg # figure patch + assert 'fill="#101418"' in svg # plot rect + + # Browser-only paints (gradients) are omitted, never fallback-painted. + gradient = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.theme(style={"background": "linear-gradient(red, blue)"}), + width=300, + height=200, + ) + assert "linear-gradient" not in gradient.figure().to_svg() + + +def test_svg_honors_tick_label_anchor() -> None: + chart = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + xy.x_axis(tick_label_anchor="right"), # mpl `ha` alias -> "end" + xy.y_axis(tick_label_anchor="center"), + width=300, + height=200, + ) + svg = chart.figure().to_svg() + # No title/axis labels, so the only text-anchor sources are tick labels: + # x pins its right edge ("end"), y centers ("middle"), nothing at "start". + assert 'text-anchor="end"' in svg + assert 'text-anchor="middle"' in svg + assert 'text-anchor="start"' not in svg + + # Defaults reproduce the classic layout: x centered, y right edge at the + # tick ("end" — labels sit left of the plot). + default = xy.line_chart( + xy.line(x=[0.0, 1.0], y=[0.0, 1.0]), + width=300, + height=200, + ) + default_svg = default.figure().to_svg() + assert 'text-anchor="middle"' in default_svg + assert 'text-anchor="end"' in default_svg + + def test_svg_stays_screen_bounded_for_large_lines() -> None: n = 2_000_000 y = np.cumsum(np.random.default_rng(1).normal(size=n)) diff --git a/uv.lock b/uv.lock index 53cdcf64..53b90935 100644 --- a/uv.lock +++ b/uv.lock @@ -755,7 +755,7 @@ wheels = [ [[package]] name = "xy" -version = "0.1.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "anywidget" }, From 0762b47945cab53cef2f7709fdbd3534dd2353ca Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:55:31 +0500 Subject: [PATCH 2/4] test: align annotation-arrow probe with lifted arrow labels Arrow labels no longer sit exactly on the shaft midpoint: the client lifts them along the shaft's upward normal so the line doesn't strike through the text (#63). The alignment probe still asserted the old center-x-equals-midpoint contract and failed once a Chromium is available (CI skips these probes). Assert the new geometry instead: no tangential drift along the shaft, and a normal offset large enough that the label box clears the line. --- tests/test_legend_resize_regression.py | 29 +++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_legend_resize_regression.py b/tests/test_legend_resize_regression.py index a90b4350..95f13b6d 100644 --- a/tests/test_legend_resize_regression.py +++ b/tests/test_legend_resize_regression.py @@ -247,12 +247,32 @@ return (rect.left + rect.right) / 2; }; const bandExpected = root.left + (view._dataPxX(2) + view._dataPxX(4)) / 2; - const arrowExpected = root.left + (view._dataPxX(0) + view._dataPxX(2)) / 2; + // The arrow label is centered on the shaft midpoint, then lifted along + // the shaft's upward normal until the box clears the line (plus a small + // margin). Measure its offset from the midpoint in the shaft's own + // frame: the tangential component must stay ~0 (still centered along + // the shaft) and the normal component must be at least the box's + // projection onto the normal (clear of the line, on the upper side). + const ax0 = view._dataPxX(0); + const ay0 = view._dataPxY(0); + const ax1 = view._dataPxX(2); + const ay1 = view._dataPxY(2); + const shaftLen = Math.hypot(ax1 - ax0, ay1 - ay0); + const tangent = [(ax1 - ax0) / shaftLen, (ay1 - ay0) / shaftLen]; + let normal = [-tangent[1], tangent[0]]; + if (normal[1] > 0) normal = [-normal[0], -normal[1]]; + const arrowRect = arrow.getBoundingClientRect(); + const offX = (arrowRect.left + arrowRect.right) / 2 - root.left - (ax0 + ax1) / 2; + const offY = (arrowRect.top + arrowRect.bottom) / 2 - root.top - (ay0 + ay1) / 2; document.body.setAttribute( "data-xy-annotation-alignment", JSON.stringify({ bandCenterError: Math.abs(centerX(band) - bandExpected), - arrowCenterError: Math.abs(centerX(arrow) - arrowExpected), + arrowTangentialError: Math.abs(offX * tangent[0] + offY * tangent[1]), + arrowNormalOffset: offX * normal[0] + offY * normal[1], + arrowRequiredClearance: + (arrowRect.width / 2) * Math.abs(normal[0]) + + (arrowRect.height / 2) * Math.abs(normal[1]), bandTransform: band.style.transform, arrowTransform: arrow.style.transform, }) @@ -470,7 +490,10 @@ def test_midpoint_annotation_labels_are_visually_centered() -> None: ) assert payload["bandCenterError"] <= 1, payload - assert payload["arrowCenterError"] <= 1, payload + # Centered along the shaft, offset only along its upward normal — far + # enough that the box clears the line instead of being struck through. + assert payload["arrowTangentialError"] <= 1, payload + assert payload["arrowNormalOffset"] >= payload["arrowRequiredClearance"], payload assert "-50%" in payload["bandTransform"], payload assert "-50%" in payload["arrowTransform"], payload From 6ed5dc2bfcf6929188e8eca07085f6b049fc3bf3 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:59:52 +0000 Subject: [PATCH 3/4] fix(svg): thread tick_label_anchor into collision model, parity with JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _axis_tick_label_layout collision check now uses the same edge-anchor-aware model as JS _tickLabelsCollide: - For is_x=True and anchor != "center": rotated labels use the perpendicular-gap test (spacing * sin(angle) < lineHeight + minGap) instead of the old centered half-extent boxes. Non-rotated edge- anchored labels check the width of the "lead" label (end → current, start → previous) against spacing. - For y-axis or centered x-axis: the original centered-extent model is unchanged (parity with JS comment: "y collision keeps the centered extent model"). Adds test_svg_tick_label_anchor_collision_parity to confirm that 9 rotated edge-anchored labels are all kept when spacing * sin(angle) exceeds the threshold, and that the unanchored (centered) path still detects the same geometry as a collision. Fixes: blocking issue from PR review — export was stride-2 downsampling labels that the JS client kept (and vice-versa for shallow angles). --- python/xy/_svg.py | 40 +++++++++++++++++++++++++++++------- tests/test_svg_export.py | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 866df035..bdb67003 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1206,6 +1206,11 @@ def _axis_tick_label_layout( raw_angle = axis.get("tick_label_angle") explicit_angle = float(raw_angle) if raw_angle is not None else None base_angle = explicit_angle or 0.0 + # y collision keeps the centered extent model: every label on an axis + # shares one anchor+angle, so an anchored y layout shifts all boxes by + # the same offset and pairwise gaps are unchanged. Mirror JS exactly. + axis_style = axis.get("style") or {} + anchor = _tick_label_anchor(axis, axis_style, "center") if is_x else "center" labels = [ { "value": value, @@ -1232,13 +1237,34 @@ def collide(items: list[dict[str, Any]]) -> bool: for item in items: rows.setdefault(int(item.get("row", 0)), []).append(item) for row in rows.values(): - last_end = -math.inf - for item in sorted(row, key=lambda candidate: float(candidate["pos"])): - half = extent(item) / 2.0 - start = float(item["pos"]) - half - if start < last_end + min_gap: - return True - last_end = float(item["pos"]) + half + row_sorted = sorted(row, key=lambda candidate: float(candidate["pos"])) + if is_x and anchor != "center": + # Edge-anchored labels all run the same direction from their + # tick. Rotated ones are parallel lines: they clear each other + # when the perpendicular gap between adjacent anchors exceeds + # the line height, regardless of horizontal bounding-box overlap. + # Mirror JS _tickLabelsCollide exactly. + for i in range(1, len(row_sorted)): + prev = row_sorted[i - 1] + label = row_sorted[i] + spacing = float(label["pos"]) - float(prev["pos"]) + angle = abs(float(label.get("angle", 0.0))) * math.pi / 180.0 + if angle: + if spacing * math.sin(angle) < font_size * 1.2 + min_gap: + return True + else: + lead = label if anchor == "end" else prev + w = max(font_size * 0.7, len(str(lead["text"])) * font_size * 0.62) + if spacing < w + min_gap: + return True + else: + last_end = -math.inf + for item in row_sorted: + half = extent(item) / 2.0 + start = float(item["pos"]) - half + if start < last_end + min_gap: + return True + last_end = float(item["pos"]) + half return False if strategy == "auto": diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index d0be4f42..9197d35b 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -103,6 +103,50 @@ def test_svg_honors_tick_label_anchor() -> None: assert 'text-anchor="end"' in default_svg +def test_svg_tick_label_anchor_collision_parity() -> None: + """Anchor-aware collision model matches JS _tickLabelsCollide. + + 9 categories, tick_label_angle=-30, tick_label_anchor="end", wide chart: + spacing * sin(30°) > lineHeight + minGap → JS keeps all 9 labels. + The old centered-extent model treated labels as ±half-extent boxes centred + on each tick and found them colliding (extent > spacing), so it would + stride-2 downsample to 5. The fixed Python exporter must also keep all 9. + """ + from xy._svg import _Scale, _axis_tick_label_layout + + # 15-char labels; font_size=11, angle=-30, anchor="end", min_gap=8. + # new model: spacing * sin(30°) = 90*0.5 = 45 > 11*1.2+8 = 21.2 → ok + # old model: extent = cos(30°)*102.3 + sin(30°)*13.2 ≈ 95.2 + # gap = 90 - 95.2 = -5.2 < 8 → would collide → stride-2 + n = 9 + cats = [f"Category_Name_{i:02d}" for i in range(n)] # 15 chars each + axis: dict = { + "kind": "category", + "categories": cats, + "range": [0.0, float(n - 1)], + "tick_label_angle": -30, + "tick_label_anchor": "end", + "tick_label_strategy": "rotate", + } + # plot_width=720px → spacing = 720/8 = 90px + scale = _Scale(axis, px0=100.0, px1=820.0) + values = [float(i) for i in range(n)] + kept = _axis_tick_label_layout(axis, values, 1.0, scale, is_x=True) + assert len(kept) == n, ( + f"anchor-aware collision model should keep all {n} labels, got {len(kept)}" + ) + + # Sanity: without the anchor the old centered-extent model is used and the + # same geometry collides, so strategy="rotate" still downsample to fit. + axis_no_anchor: dict = {**axis} + del axis_no_anchor["tick_label_anchor"] + kept_no_anchor = _axis_tick_label_layout(axis_no_anchor, values, 1.0, scale, is_x=True) + assert len(kept_no_anchor) < n, ( + "centered-extent model should find collision (geometry not wide enough) " + f"but kept {len(kept_no_anchor)} of {n}" + ) + + def test_svg_stays_screen_bounded_for_large_lines() -> None: n = 2_000_000 y = np.cumsum(np.random.default_rng(1).normal(size=n)) From 16f50db1a83115c9340020655493e81df4da1b87 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:01:44 +0000 Subject: [PATCH 4/4] =?UTF-8?q?style:=20rename=20row=5Fsorted=E2=86=92sort?= =?UTF-8?q?ed=5Frow,=20label=E2=86=92curr,=20cats=E2=86=92categories=20per?= =?UTF-8?q?=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/xy/_svg.py | 16 ++++++++-------- tests/test_svg_export.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index bdb67003..4dbd458a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1237,29 +1237,29 @@ def collide(items: list[dict[str, Any]]) -> bool: for item in items: rows.setdefault(int(item.get("row", 0)), []).append(item) for row in rows.values(): - row_sorted = sorted(row, key=lambda candidate: float(candidate["pos"])) + sorted_row = sorted(row, key=lambda candidate: float(candidate["pos"])) if is_x and anchor != "center": # Edge-anchored labels all run the same direction from their # tick. Rotated ones are parallel lines: they clear each other # when the perpendicular gap between adjacent anchors exceeds # the line height, regardless of horizontal bounding-box overlap. # Mirror JS _tickLabelsCollide exactly. - for i in range(1, len(row_sorted)): - prev = row_sorted[i - 1] - label = row_sorted[i] - spacing = float(label["pos"]) - float(prev["pos"]) - angle = abs(float(label.get("angle", 0.0))) * math.pi / 180.0 + for i in range(1, len(sorted_row)): + prev = sorted_row[i - 1] + curr = sorted_row[i] + spacing = float(curr["pos"]) - float(prev["pos"]) + angle = abs(float(curr.get("angle", 0.0))) * math.pi / 180.0 if angle: if spacing * math.sin(angle) < font_size * 1.2 + min_gap: return True else: - lead = label if anchor == "end" else prev + lead = curr if anchor == "end" else prev w = max(font_size * 0.7, len(str(lead["text"])) * font_size * 0.62) if spacing < w + min_gap: return True else: last_end = -math.inf - for item in row_sorted: + for item in sorted_row: half = extent(item) / 2.0 start = float(item["pos"]) - half if start < last_end + min_gap: diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 9197d35b..025c487c 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -116,13 +116,13 @@ def test_svg_tick_label_anchor_collision_parity() -> None: # 15-char labels; font_size=11, angle=-30, anchor="end", min_gap=8. # new model: spacing * sin(30°) = 90*0.5 = 45 > 11*1.2+8 = 21.2 → ok - # old model: extent = cos(30°)*102.3 + sin(30°)*13.2 ≈ 95.2 - # gap = 90 - 95.2 = -5.2 < 8 → would collide → stride-2 + # old model: extent = cos(30°)*109.1 + sin(30°)*13.2 ≈ 101.1 + # gap = 90 - 101.1 = -11.1 < 8 → would collide → stride-2 n = 9 - cats = [f"Category_Name_{i:02d}" for i in range(n)] # 15 chars each + categories = [f"Category_Name_{i:02d}" for i in range(n)] # 16 chars each axis: dict = { "kind": "category", - "categories": cats, + "categories": categories, "range": [0.0, float(n - 1)], "tick_label_angle": -30, "tick_label_anchor": "end",