diff --git a/docs/engineering/styling.md b/docs/engineering/styling.md index 2bf13f3..9d7810c 100644 --- a/docs/engineering/styling.md +++ b/docs/engineering/styling.md @@ -128,6 +128,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( @@ -231,7 +232,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% | @@ -245,6 +246,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 0000000..211d2fe --- /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 8f43c2a..18e15b1 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 0000000..de5b3bb Binary files /dev/null and b/examples/exploitbench.png differ diff --git a/js/src/20_theme.js b/js/src/20_theme.js index 9762d19..1afd9ba 100644 --- a/js/src/20_theme.js +++ b/js/src/20_theme.js @@ -128,6 +128,15 @@ const XY_CHROME_CSS = ` @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; it stays outside the base layer because it + overrides host CSS rather than providing an overridable default. */ +.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 e64cc11..e1f4429 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). @@ -788,6 +794,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" }, @@ -851,6 +858,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 @@ -2184,6 +2195,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); @@ -2876,6 +2888,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 angle = Number(axis ? axis.tick_label_angle : undefined); return Number.isFinite(angle) ? angle : null; @@ -2899,7 +2923,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); @@ -2908,6 +2932,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); @@ -2920,11 +2963,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); } @@ -2941,12 +2984,16 @@ 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 })); let strategy = strategyValue; 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"; @@ -2962,8 +3009,8 @@ class ChartView { // Strategies handle collisions; a non-colliding label set stays intact // even under an explicit "hide" (matches the Python exporters). - if (this._tickLabelsCollide(out, dim, fontSize, minGap)) { - out = this._downsampleTickLabels(out, dim, fontSize, minGap); + if (this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { + out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } @@ -2971,6 +3018,19 @@ class ChartView { _xTickLabelTransform(axis, angle) { const value = Number(angle || 0); const side = axis && axis.side === "top" ? "top" : "bottom"; + // An explicit anchor (mpl `ha`) pins that edge as the transform origin, + // so a rotated label pivots about the point pinned at the tick instead + // of seesawing its trailing half into the plot. Unset, the anchor is + // derived from the rotation direction below. + const anchor = this._axisTickLabelAnchor(axis); + if (anchor) { + const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; + const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; + return { + transform: `translateX(${shift}) rotate(${value}deg)`, + origin: `${originX} ${side === "top" ? "bottom" : "top"}`, + }; + } if (value === 0) { return { transform: "translateX(-50%)", @@ -3261,12 +3321,12 @@ 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), + ); 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 placement = this._xTickLabelTransform(xAxis, item.angle); @@ -3319,12 +3379,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 extraYAxes) { const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); @@ -3336,11 +3405,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 && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" @@ -3714,17 +3779,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 8199bd6..7ffa8ed 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -150,6 +150,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, @@ -198,6 +199,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"), @@ -444,6 +448,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) @@ -936,6 +941,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 @@ -967,6 +975,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 472aaea..0740f02 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -39,9 +39,12 @@ _css, _legend_layout, _lut, + _px_size, _resolve_static_css_vars, _Scale, + _solid_paint, _step_arrays, + _tick_label_anchor, axis_ticks, hexbin_ring, layout, @@ -122,6 +125,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)) @@ -638,22 +653,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) @@ -901,17 +936,21 @@ def emit_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + # An explicit tick_label_anchor (axis spec or style) overrides the + # side-derived default, matching the browser client and SVG export. + explicit_anchor = _tick_label_anchor(axis, axis_style, "") for item in items: flag = rotation_flag(float(item["angle"])) if is_x: row_offset = float(item["row"]) * (font_size + 4) x = float(item["pos"]) y = py0 - 7 - row_offset if side == "top" else py1 + 15 + row_offset - anchor = 1 + anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else 1 else: x = px1 + 8 if side == "right" else px0 - 8 y = float(item["pos"]) + 4 - anchor = 0 if side == "right" else 2 + default_anchor = 0 if side == "right" else 2 + anchor = _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor cmd.text(x, y, anchor | flag, font_size, tick_color, item["text"]) emit_tick_labels(xa, xlab, xstep, sx, is_x=True) @@ -1114,7 +1153,7 @@ def _emit_annotations( 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 3c5b653..44deea7 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -594,6 +594,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, @@ -1165,6 +1204,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, @@ -1191,13 +1235,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 + 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(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 = 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 sorted_row: + 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": @@ -1363,6 +1428,11 @@ def append_tick_labels( ) font_size = _axis_tick_font_size(axis) side = axis.get("side", "bottom" if is_x else "left") + # An explicit tick_label_anchor (axis spec or style) overrides the + # angle/side-derived default. Anchored labels rotate about the tick + # point (the rotate() pivot below), so anchor and rotation compose — + # matching the browser client. + explicit_anchor = _tick_label_anchor(axis, axis_style, "") for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x): angle = float(item["angle"]) if is_x: @@ -1373,7 +1443,9 @@ def append_tick_labels( if side == "top" else plot["y"] + plot["h"] + 16 + row_offset ) - if angle == 0: + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + elif angle == 0: anchor = "middle" elif (side == "bottom" and angle < 0) or (side == "top" and angle > 0): anchor = "end" @@ -1382,7 +1454,10 @@ def append_tick_labels( else: x = plot["x"] + plot["w"] + 8 if side == "right" else plot["x"] - 8 y = float(item["pos"]) + 4 - anchor = "start" if side == "right" else "end" + if explicit_anchor: + anchor = _TEXT_ANCHORS[explicit_anchor] + else: + anchor = "start" if side == "right" else "end" transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" labels.append( f' tuple[float, float, float]: ) 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}" @@ -1777,7 +1869,7 @@ def _annotation_svg( 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 bf59280..bfee732 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 9588029..e82e48c 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -52,6 +52,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 @@ -204,6 +205,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) @@ -1932,6 +1934,7 @@ def x_axis( tick_labels: Optional[Sequence[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: Optional[dict[str, StyleValue]] = None, @@ -1953,6 +1956,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. @@ -1980,6 +1989,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" ), @@ -2004,6 +2014,7 @@ def y_axis( tick_labels: Optional[Sequence[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: Optional[dict[str, StyleValue]] = None, @@ -2025,6 +2036,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. @@ -2052,6 +2069,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" ), @@ -2243,6 +2261,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, @@ -2256,7 +2275,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. @@ -2269,6 +2293,7 @@ def theme( merged.update( _theme_tokens( { + "background": background, "plot_background": plot_background, "grid_color": grid_color, "axis_color": axis_color, @@ -2517,6 +2542,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, @@ -2988,7 +3014,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 7be1c75..2fef168 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -274,6 +274,15 @@ const XY_CHROME_CSS = ` @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; it stays outside the base layer because it + overrides host CSS rather than providing an overridable default. */ +.cell-output-ipywidget-background:has(.xy[data-xy-own-bg]){background:transparent!important} `; function ensureChromeStylesheet(node) { let root = node && node.getRootNode ? node.getRootNode() : document; @@ -1835,6 +1844,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; @@ -2323,6 +2333,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" }, @@ -2377,6 +2388,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); @@ -3533,6 +3545,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); @@ -4168,6 +4181,17 @@ _axisTickLabelStrategy(axis) { const value = String((axis && axis.tick_label_strategy) || "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 angle = Number(axis ? axis.tick_label_angle : undefined); return Number.isFinite(angle) ? angle : null; @@ -4187,7 +4211,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); @@ -4196,6 +4220,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); @@ -4207,11 +4246,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); } @@ -4227,12 +4266,13 @@ 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 })); let strategy = strategyValue; 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"; @@ -4244,14 +4284,23 @@ 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 (this._tickLabelsCollide(out, dim, fontSize, minGap)) { -out = this._downsampleTickLabels(out, dim, fontSize, minGap); +if (this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { +out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } _xTickLabelTransform(axis, angle) { const value = Number(angle || 0); const side = axis && axis.side === "top" ? "top" : "bottom"; +const anchor = this._axisTickLabelAnchor(axis); +if (anchor) { +const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; +const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; +return { +transform: `translateX(${shift}) rotate(${value}deg)`, +origin: `${originX} ${side === "top" ? "bottom" : "top"}`, +}; +} if (value === 0) { return { transform: "translateX(-50%)", @@ -4510,12 +4559,12 @@ 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), ); +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 placement = this._xTickLabelTransform(xAxis, item.angle); @@ -4568,12 +4617,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 extraYAxes) { const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); @@ -4585,11 +4639,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 && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" @@ -4885,14 +4935,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 bee879c..1db6738 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -275,6 +275,15 @@ const XY_CHROME_CSS = ` @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; it stays outside the base layer because it + overrides host CSS rather than providing an overridable default. */ +.cell-output-ipywidget-background:has(.xy[data-xy-own-bg]){background:transparent!important} `; function ensureChromeStylesheet(node) { let root = node && node.getRootNode ? node.getRootNode() : document; @@ -1836,6 +1845,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; @@ -2324,6 +2334,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" }, @@ -2378,6 +2389,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); @@ -3534,6 +3546,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); @@ -4169,6 +4182,17 @@ _axisTickLabelStrategy(axis) { const value = String((axis && axis.tick_label_strategy) || "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 angle = Number(axis ? axis.tick_label_angle : undefined); return Number.isFinite(angle) ? angle : null; @@ -4188,7 +4212,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); @@ -4197,6 +4221,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); @@ -4208,11 +4247,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); } @@ -4228,12 +4267,13 @@ 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 })); let strategy = strategyValue; 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"; @@ -4245,14 +4285,23 @@ 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 (this._tickLabelsCollide(out, dim, fontSize, minGap)) { -out = this._downsampleTickLabels(out, dim, fontSize, minGap); +if (this._tickLabelsCollide(out, dim, fontSize, minGap, anchor)) { +out = this._downsampleTickLabels(out, dim, fontSize, minGap, anchor); } return out; } _xTickLabelTransform(axis, angle) { const value = Number(angle || 0); const side = axis && axis.side === "top" ? "top" : "bottom"; +const anchor = this._axisTickLabelAnchor(axis); +if (anchor) { +const shift = anchor === "end" ? "-100%" : anchor === "start" ? "0%" : "-50%"; +const originX = anchor === "end" ? "right" : anchor === "start" ? "left" : "center"; +return { +transform: `translateX(${shift}) rotate(${value}deg)`, +origin: `${originX} ${side === "top" ? "bottom" : "top"}`, +}; +} if (value === 0) { return { transform: "translateX(-50%)", @@ -4511,12 +4560,12 @@ 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), ); +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 placement = this._xTickLabelTransform(xAxis, item.angle); @@ -4569,12 +4618,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 extraYAxes) { const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); @@ -4586,11 +4640,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 && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" @@ -4886,14 +4936,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 08620bc..c24a151 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 8bcd2d8..b0f11c4 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 e841d37..ee75627 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -481,6 +481,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_figure_dom_class_strings_covers_every_class_carrying_surface(): """`Figure.dom_class_strings()` is the Tailwind scan inventory (see its docstring): chart root, chrome slots, per-mark styles, annotation nodes.""" @@ -1399,6 +1413,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"), @@ -1409,14 +1424,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 506cd28..156d1e3 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_legend_resize_regression.py b/tests/test_legend_resize_regression.py index 9c15813..ed90c13 100644 --- a/tests/test_legend_resize_regression.py +++ b/tests/test_legend_resize_regression.py @@ -247,26 +247,33 @@ return [(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2]; }; const [bandCenterX] = center(band); - const [arrowCenterX, arrowCenterY] = center(arrow); const bandExpected = root.left + (view._dataPxX(2) + view._dataPxX(4)) / 2; - const arrowX0 = root.left + view._dataPxX(0); - const arrowY0 = root.top + view._dataPxY(0); - const arrowX1 = root.left + view._dataPxX(2); - const arrowY1 = root.top + view._dataPxY(2); - const arrowExpectedX = (arrowX0 + arrowX1) / 2; - const arrowExpectedY = (arrowY0 + arrowY1) / 2; - const arrowDx = arrowX1 - arrowX0; - const arrowDy = arrowY1 - arrowY0; - const arrowLength = Math.hypot(arrowDx, arrowDy); - const arrowAlongError = Math.abs( - ((arrowCenterX - arrowExpectedX) * arrowDx + - (arrowCenterY - arrowExpectedY) * arrowDy) / arrowLength - ); + // 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(bandCenterX - bandExpected), - arrowCenterError: arrowAlongError, + 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, }) @@ -484,7 +491,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 diff --git a/tests/test_png_export.py b/tests/test_png_export.py index ea9fbbb..fb8d4f6 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -182,6 +182,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_raster_legend_text_honors_theme_text_color() -> None: # Red is reserved for the theme text color; every other paint is green, # and the tick labels are overridden, so red pixels can only come from diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index fceeb77..60fe056 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -574,12 +574,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 34d35a0..3fca7ab 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -13,7 +13,7 @@ import xy from xy._figure import Figure -from xy._svg import COLORMAP_STOPS +from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _Scale ROOT = Path(__file__).resolve().parents[1] @@ -55,6 +55,96 @@ 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_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. + """ + # 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°)*109.1 + sin(30°)*13.2 ≈ 101.1 + # gap = 90 - 101.1 = -11.1 < 8 → would collide → stride-2 + n = 9 + categories = [f"Category_Name_{i:02d}" for i in range(n)] # 16 chars each + axis: dict = { + "kind": "category", + "categories": categories, + "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_legend_text_honors_theme_text_color() -> None: chart = xy.line_chart( xy.line(x=[0.0, 1.0], y=[0.0, 1.0], name="walk"),