diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 0000000..cbcabe0 --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,218 @@ +name: Prepare Release + +# Run manually from the Actions tab. +# Creates a branch + PR that bumps the version, builds the changelog, +# and updates the docs switcher — ready to review before tagging. +on: + workflow_dispatch: + inputs: + bump: + description: "Version component to bump" + required: true + type: choice + options: + - minor + - bugfix + - major + - pre-release # increments the bN counter on the current base version + beta: + description: "Mark as beta pre-release (adds bN suffix; always true for pre-release)" + required: false + type: boolean + default: false + +permissions: + contents: write # push branch + pull-requests: write # open PR + +jobs: + prepare: + name: Prepare release PR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + enable-cache: true + + - name: Install dev dependencies + run: uv sync + + # ── Compute the new version ────────────────────────────────────────── + - name: Compute new version + id: version + env: + BUMP: ${{ inputs.bump }} + IS_BETA: ${{ inputs.beta }} + run: | + CURRENT=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + export CURRENT_VERSION="$CURRENT" + + NEW_VERSION=$(python3 - <<'PYEOF' + import re, os + + current = os.environ["CURRENT_VERSION"] + bump = os.environ["BUMP"] + is_beta = os.environ["IS_BETA"].lower() == "true" + + m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?", current) + major = int(m.group(1)) + minor = int(m.group(2)) + patch = int(m.group(3)) + beta_n = int(m.group(4)) if m.group(4) else None + + if bump == "major": + major, minor, patch = major + 1, 0, 0 + elif bump == "minor": + minor, patch = minor + 1, 0 + elif bump == "bugfix": + patch += 1 + elif bump == "pre-release": + # Keep the same base; just walk the beta counter forward. + is_beta = True + beta_n = (beta_n or 0) + 1 + + if is_beta: + if bump != "pre-release": + beta_n = 1 # fresh beta series for the new base + print(f"{major}.{minor}.{patch}b{beta_n}", end="") + else: + print(f"{major}.{minor}.{patch}", end="") + PYEOF + ) + + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "branch=release/v$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "is_beta=${{ inputs.beta }}" >> "$GITHUB_OUTPUT" + echo "Bumping (${{ inputs.bump }}): $CURRENT → $NEW_VERSION" + + # ── Bump version strings ───────────────────────────────────────────── + - name: Bump version in pyproject.toml + run: | + sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.new_version }}"/' pyproject.toml + + - name: Bump version in docs/conf.py + run: | + sed -i 's/^release = ".*"/release = "${{ steps.version.outputs.new_version }}"/' docs/conf.py + + # ── Build changelog ────────────────────────────────────────────────── + - name: Build changelog with towncrier + run: | + FRAGMENT_COUNT=$(find upcoming_changes -maxdepth 1 -name "*.rst" \ + ! -name "README.rst" | wc -l) + if [ "$FRAGMENT_COUNT" -eq 0 ]; then + echo "⚠ No news fragments found — skipping towncrier (CHANGELOG.rst unchanged)." + else + uvx towncrier build --yes --version "${{ steps.version.outputs.new_version }}" + fi + + # ── Update docs switcher.json ──────────────────────────────────────── + - name: Update docs/switcher.json + env: + VERSION_TAG: ${{ steps.version.outputs.tag }} + IS_BETA: ${{ inputs.beta }} + shell: python + run: | + import json, re, pathlib, os + + version = os.environ["VERSION_TAG"] + is_beta = os.environ["IS_BETA"].lower() == "true" + + path = pathlib.Path("docs/_root/switcher.json") + text = path.read_text() + # The file may contain a trailing comma; strip it before parsing. + text_clean = re.sub(r",(\s*[\]\}])", r"\1", text) + entries = json.loads(text_clean) + + # Remove any existing entry for this version (makes the step idempotent). + entries = [e for e in entries if e.get("version") != version] + + label = f"{version} (beta)" if is_beta else f"{version} (stable)" + url = f"https://cssfrancis.github.io/anyplotlib/{version}/" + # Insert right after the "dev" entry so newest stable floats to top. + entries.insert(1, {"name": label, "version": version, "url": url}) + + path.write_text(json.dumps(entries, indent=2) + "\n") + + # ── Update root redirect for stable releases ───────────────────────── + - name: Update root redirect (stable releases only) + if: ${{ inputs.beta == false && inputs.bump != 'pre-release' }} + env: + VERSION_TAG: ${{ steps.version.outputs.tag }} + shell: python + run: | + import re, pathlib, os + + version = os.environ["VERSION_TAG"] + path = pathlib.Path("docs/_root/index.html") + text = path.read_text() + + text = re.sub(r'(content="0; url=)[^"]+(")', rf"\g<1>{version}/\2", text) + text = re.sub(r'(rel="canonical" href=")[^"]+(")', rf"\g<1>{version}/\2", text) + text = re.sub(r'([^<]*)', rf"\g<1>{version}/\2", text) + text = re.sub(r'(Redirecting to )[^<]*()', + rf"\g<1>{version} documentation\2", text) + + path.write_text(text) + + # ── Commit and push ────────────────────────────────────────────────── + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit release changes + run: | + git checkout -b "${{ steps.version.outputs.branch }}" + + # Stage version bumps, updated changelog, and consumed fragments. + git add pyproject.toml docs/conf.py CHANGELOG.rst + git add docs/_root/switcher.json docs/_root/index.html + git add -A upcoming_changes/ # stages deleted fragment files + + git commit -m "chore: prepare release ${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.branch }}" + + # ── Open pull request ──────────────────────────────────────────────── + - name: Open pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + gh pr create \ + --title "Release ${TAG}" \ + --base main \ + --head "${BRANCH}" \ + --body "## Release ${TAG} + + > Auto-generated by the **Prepare Release** workflow. + + ### What changed + - Version bumped to \`${TAG}\` in \`pyproject.toml\` and \`docs/conf.py\` + - \`CHANGELOG.rst\` updated from towncrier fragments + - \`docs/_root/switcher.json\` updated with the new version entry + $([ '${{ inputs.beta }}' = 'false' ] && echo '- Root redirect updated to point to this release' || echo '') + + ### Review checklist + - [ ] \`CHANGELOG.rst\` reads well — edit the fragment text directly if needed + - [ ] Version strings are correct in \`pyproject.toml\` and \`docs/conf.py\` + - [ ] \`switcher.json\` has the right label and URL + - [ ] CI passes + + ### After merging + Create and push the tag to trigger the Release and Docs workflows: + \`\`\`bash + git fetch origin + git tag ${TAG} origin/main + git push origin ${TAG} + \`\`\`" + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0ddf341 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release + +# Fires when a version tag is pushed (manually, after the Prepare Release PR +# is merged and reviewed). +# +# Jobs: +# build - build wheel + sdist with uv +# publish - upload to PyPI via OIDC trusted publishing (no API token needed) +# release - create a GitHub Release with the dist files and changelog notes + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write # create GitHub Releases and upload assets + id-token: write # OIDC token for PyPI trusted publishing + +jobs: + # -------------------------------------------------------------------------- + build: + name: Build distribution + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + enable-cache: true + + - name: Build wheel and sdist + run: uv build + + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + # -------------------------------------------------------------------------- + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + + environment: + name: pypi + url: https://pypi.org/p/anyplotlib + + steps: + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + # Trusted publishing - no API token required. + # One-time setup on pypi.org: add a pending publisher for + # Owner: CSSFrancis Repo: anyplotlib Workflow: release.yml + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # -------------------------------------------------------------------------- + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Extract release notes from CHANGELOG.rst + env: + TAG: ${{ github.ref_name }} + shell: python + run: | + import re, pathlib, os + tag = os.environ["TAG"] + text = pathlib.Path("CHANGELOG.rst").read_text() + parts = re.split(r"(?m)(?=^\S[^\n]*\n=+\n)", text) + notes = next((p.strip() for p in parts if p.strip().startswith(tag)), None) + fallback = "Release " + tag + "\n" + "=" * (len(tag) + 8) + "\n\nSee CHANGELOG.rst." + pathlib.Path("release_notes.rst").write_text(notes if notes else fallback) + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + PRERELEASE_FLAG="" + if [[ "$TAG" == *b* ]]; then PRERELEASE_FLAG="--prerelease"; fi + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file release_notes.rst \ + $PRERELEASE_FLAG \ + dist/* diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..6cdc35c --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,19 @@ +========= +Changelog +========= + +All notable changes to **anyplotlib** are documented here. + +Fragment files in ``upcoming_changes/`` are assembled into this file by +`towncrier `_ when a release is prepared +(see ``upcoming_changes/README.rst`` for contributor instructions). + +.. towncrier release notes start + +v0.1.0 (2026-04-12) +==================== + +Initial release. Includes ``Figure``, ``Axes``, ``GridSpec``, ``subplots``, +``Plot1D``, ``Plot2D``, ``PlotMesh``, ``Plot3D``, ``PlotBar``, a full marker +system, interactive overlay widgets, and a two-tier callback registry. + diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index b5f0f74..220ea59 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,5 +1,5 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots -from anyplotlib.figure_plots import Axes, Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar +from anyplotlib.figure_plots import Axes, InsetAxes, Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -14,7 +14,7 @@ __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", - "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", + "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "CallbackRegistry", "Event", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 1e94563..b044d3b 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -25,7 +25,8 @@ from __future__ import annotations import json, pathlib import anywidget, numpy as np, traitlets -from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, Plot3D, PlotBar +from anyplotlib.figure_plots import (GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, + Plot3D, PlotBar, InsetAxes, _plot_kind) from anyplotlib.callbacks import Event __all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"] @@ -124,6 +125,7 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._sharey = sharey self._axes_map: dict = {} self._plots_map: dict = {} + self._insets_map: dict = {} with self.hold_trait_notifications(): self.fig_width = figsize[0] self.fig_height = figsize[1] @@ -263,10 +265,7 @@ def _mg(flag, key): plot = self._plots_map.get(pid) panel_specs.append({ "id": pid, - "kind": ("3d" if isinstance(plot, Plot3D) - else "2d" if isinstance(plot, (Plot2D, PlotMesh)) - else "bar" if isinstance(plot, PlotBar) - else "1d"), + "kind": _plot_kind(plot) if plot else "1d", "row_start": s.row_start, "row_stop": s.row_stop, "col_start": s.col_start, @@ -275,6 +274,23 @@ def _mg(flag, key): "panel_height": ph, }) + inset_specs = [] + for pid, inset_ax in self._insets_map.items(): + plot = self._plots_map.get(pid) + pw = max(64, round(self.fig_width * inset_ax.w_frac)) + ph = max(64, round(self.fig_height * inset_ax.h_frac)) + inset_specs.append({ + "id": pid, + "kind": _plot_kind(plot) if plot else "1d", + "w_frac": inset_ax.w_frac, + "h_frac": inset_ax.h_frac, + "corner": inset_ax.corner, + "title": inset_ax.title, + "panel_width": pw, + "panel_height": ph, + "inset_state": inset_ax._inset_state, + }) + self.layout_json = json.dumps({ "nrows": self._nrows, "ncols": self._ncols, @@ -284,8 +300,48 @@ def _mg(flag, key): "fig_height": self.fig_height, "panel_specs": panel_specs, "share_groups": share_groups, + "inset_specs": inset_specs, }) + # ── inset creation ──────────────────────────────────────────────────────── + def add_inset(self, w_frac: float, h_frac: float, *, + corner: str = "top-right", title: str = "") -> "InsetAxes": + """Create and return a floating inset axes. + + The inset overlays the figure at the specified corner. Call + plot-factory methods on the returned :class:`InsetAxes` to attach + data:: + + inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom") + inset.imshow(data) # returns Plot2D + inset.plot(profile) # returns Plot1D + + Parameters + ---------- + w_frac, h_frac : float + Width and height as fractions of the figure size (0–1). + corner : str, optional + Positioning corner: ``"top-right"`` (default), ``"top-left"``, + ``"bottom-right"``, or ``"bottom-left"``. + title : str, optional + Text displayed in the inset title bar. + + Returns + ------- + InsetAxes + """ + return InsetAxes(self, w_frac, h_frac, corner=corner, title=title) + + def _register_inset(self, inset_ax: "InsetAxes", plot) -> None: + """Register an inset plot, allocating its trait and updating layout.""" + pid = plot._id + if not self.has_trait(f"panel_{pid}_json"): + self.add_traits(**{f"panel_{pid}_json": traitlets.Unicode("{}").tag(sync=True)}) + self._plots_map[pid] = plot + self._insets_map[pid] = inset_ax + self._push(pid) + self._push_layout() + @traitlets.observe("fig_width", "fig_height") def _on_resize(self, change) -> None: self._push_layout() @@ -313,6 +369,16 @@ def _on_event(self, change) -> None: data = {k: v for k, v in msg.items() if k not in ("source", "panel_id", "event_type", "widget_id")} + # Inset state changes are handled before regular plot dispatch + if event_type == "on_inset_state_change": + inset_ax = self._insets_map.get(panel_id) + if inset_ax is not None: + new_state = data.get("new_state", "normal") + if new_state in ("normal", "minimized", "maximized"): + inset_ax._inset_state = new_state + self._push_layout() + return + plot = self._plots_map.get(panel_id) if plot is None: return diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 2ce1ca5..5ecc384 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -175,6 +175,19 @@ function render({ model, el }) { gridDiv.style.cssText = `display:grid;gap:4px;background:${theme.bg};padding:8px;border-radius:4px;`; outerDiv.appendChild(gridDiv); + // ── Inset overlay container ─────────────────────────────────────────────── + // Covers the grid content area (inside gridDiv's 8 px padding). + // Individual insetDivs restore pointer-events:all so they capture mouse events. + const insetsContainer = document.createElement('div'); + insetsContainer.style.cssText = + 'position:absolute;top:8px;left:8px;pointer-events:none;z-index:20;overflow:visible;'; + outerDiv.appendChild(insetsContainer); + + // Inset layout constants + const INSET_TITLE_H = 22; // px — title bar height + const INSET_GAP = 8; // px — gap between stacked insets in same corner + const INSET_MARGIN = 10; // px — distance from figure edge to first inset + // Resize handle (figure-level) const resizeHandle = document.createElement('div'); resizeHandle.style.cssText = @@ -343,73 +356,70 @@ function render({ model, el }) { _resizePanelDOM(spec.id, spec.panel_width, spec.panel_height); } } + + // Handle inset panels + const insetSpecs = layout.inset_specs || []; + for (const spec of insetSpecs) { + seen.add(spec.id); + const existing = panels.get(spec.id); + if (!existing) { + _createInsetDOM(spec); + } else { + existing.insetSpec = spec; + } + } + for (const [id, p] of panels) { if (!seen.has(id)) { p.cell.remove(); panels.delete(id); } } - } - function _createPanelDOM(id, kind, pw, ph, spec) { - const cell = document.createElement('div'); - cell.style.cssText = 'position:relative;overflow:visible;line-height:0;display:flex;justify-content:center;align-items:flex-start;'; - cell.style.gridRow = `${spec.row_start+1} / ${spec.row_stop+1}`; - cell.style.gridColumn = `${spec.col_start+1} / ${spec.col_stop+1}`; - gridDiv.appendChild(cell); + // Update insetsContainer size and reposition all insets + insetsContainer.style.width = (layout.fig_width || 640) + 'px'; + insetsContainer.style.height = (layout.fig_height || 480) + 'px'; + if (insetSpecs.length) _applyAllInsetStates(layout); + } + // ── _buildCanvasStack ───────────────────────────────────────────────────── + // Creates the canvas/element stack for one panel kind and appends the + // top-level wrapper to `outerContainer`. Returns all canvas/element refs. + // Used by both _createPanelDOM (cell → gridDiv) and _createInsetDOM + // (contentDiv → insetDiv). + function _buildCanvasStack(kind, pw, ph, outerContainer) { let plotCanvas, overlayCanvas, markersCanvas, statusBar; let xAxisCanvas=null, yAxisCanvas=null, scaleBar=null; - let _p2d = null; // extra 2D DOM refs, null for 1D panels - let _wrapNode = null; // container to which statsDiv is appended + let cbCanvas=null, cbCtx=null, plotWrap=null, wrapNode=null; if (kind === '2d') { - // ── 2D branch ────────────────────────────────────────────────────────── - // The outer container is exactly pw×ph — same as the 1D canvas. - // Inside it everything is absolutely positioned to mirror 1D's _plotRect1d: - // image area : [PAD_L, PAD_T] → [pw-PAD_R, ph-PAD_B] - // y-axis : [0, PAD_T] → [PAD_L, ph-PAD_B] - // x-axis : [PAD_L, ph-PAD_B] → [pw-PAD_R, ph] - // This makes the bottom-left corner of the image/plot areas line up exactly. - - const plotWrap = document.createElement('div'); - plotWrap.style.cssText = `position:relative;display:inline-block;line-height:0;` + + plotWrap = document.createElement('div'); + plotWrap.style.cssText = `position:relative;display:inline-block;vertical-align:top;line-height:0;` + `width:${pw}px;height:${ph}px;overflow:visible;flex-shrink:0;`; - // Image canvas — positioned at the inner plot area plotCanvas = document.createElement('canvas'); - plotCanvas.style.cssText = `position:absolute;display:block;border-radius:2px;background:${theme.bgCanvas};`; - - // Overlay + marker canvases (same size as plotCanvas, stacked on top) + plotCanvas.style.cssText = + `position:absolute;display:block;border-radius:2px;background:${theme.bgCanvas};`; overlayCanvas = document.createElement('canvas'); - overlayCanvas.style.cssText = 'position:absolute;z-index:5;cursor:default;pointer-events:all;outline:none;'; + overlayCanvas.style.cssText = + 'position:absolute;z-index:5;cursor:default;pointer-events:all;outline:none;'; overlayCanvas.tabIndex = 0; markersCanvas = document.createElement('canvas'); markersCanvas.style.cssText = 'position:absolute;pointer-events:none;z-index:6;'; - - // Scale bar: single canvas drawn on demand scaleBar = document.createElement('canvas'); - scaleBar.style.cssText = - 'position:absolute;pointer-events:none;display:none;z-index:7;'; - const sbLine = null; // unused — drawing handled by canvas - const sbLabel = null; // unused — drawing handled by canvas - plotWrap.appendChild(scaleBar); - - // Status bar: absolute, bottom-left of image area + scaleBar.style.cssText = 'position:absolute;pointer-events:none;display:none;z-index:7;'; statusBar = document.createElement('div'); statusBar.style.cssText = - 'position:absolute;padding:2px 6px;' + - 'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' + - 'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;'; - - // y-axis canvas: left gutter [0, PAD_T]..[PAD_L, ph-PAD_B] + 'position:absolute;padding:2px 6px;background:rgba(0,0,0,0.55);color:white;' + + 'font-size:10px;font-family:monospace;border-radius:4px;pointer-events:none;' + + 'white-space:nowrap;display:none;z-index:9;'; yAxisCanvas = document.createElement('canvas'); - yAxisCanvas.style.cssText = `position:absolute;display:none;background:${theme.axisBg};`; - - // x-axis canvas: bottom gutter [PAD_L, ph-PAD_B]..[pw-PAD_R, ph] + yAxisCanvas.style.cssText = + `position:absolute;display:none;background:${theme.axisBg};`; xAxisCanvas = document.createElement('canvas'); - xAxisCanvas.style.cssText = `position:absolute;display:none;background:${theme.axisBg};`; - - // Colorbar canvas: narrow strip (16 px) to the right of the image area - const cbCanvas = document.createElement('canvas'); - cbCanvas.style.cssText = 'position:absolute;display:none;pointer-events:none;border-radius:0 2px 2px 0;'; + xAxisCanvas.style.cssText = + `position:absolute;display:none;background:${theme.axisBg};`; + cbCanvas = document.createElement('canvas'); + cbCanvas.style.cssText = + 'position:absolute;display:none;pointer-events:none;border-radius:0 2px 2px 0;'; + cbCtx = cbCanvas.getContext('2d'); plotWrap.appendChild(plotCanvas); plotWrap.appendChild(overlayCanvas); @@ -417,114 +427,235 @@ function render({ model, el }) { plotWrap.appendChild(yAxisCanvas); plotWrap.appendChild(xAxisCanvas); plotWrap.appendChild(cbCanvas); + plotWrap.appendChild(scaleBar); plotWrap.appendChild(statusBar); - cell.appendChild(plotWrap); - - const cbCtx = cbCanvas.getContext('2d'); - _p2d = { cbCanvas, cbCtx, plotWrap }; - _wrapNode = plotWrap; + outerContainer.appendChild(plotWrap); + wrapNode = plotWrap; } else if (kind === '3d') { - // ── 3D branch: one full-panel plotCanvas + overlayCanvas on top ─────── plotCanvas = document.createElement('canvas'); - plotCanvas.style.cssText = `display:block;border-radius:2px;background:${theme.bgPlot};`; - + plotCanvas.style.cssText = + `display:block;border-radius:2px;background:${theme.bgPlot};`; const wrap3 = document.createElement('div'); wrap3.style.cssText = 'position:relative;display:inline-block;line-height:0;'; wrap3.appendChild(plotCanvas); - cell.appendChild(wrap3); - + outerContainer.appendChild(wrap3); overlayCanvas = document.createElement('canvas'); - overlayCanvas.style.cssText = 'position:absolute;top:0;left:0;z-index:5;pointer-events:all;outline:none;'; + overlayCanvas.style.cssText = + 'position:absolute;top:0;left:0;z-index:5;pointer-events:all;outline:none;'; wrap3.appendChild(overlayCanvas); - markersCanvas = document.createElement('canvas'); - markersCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:6;display:none;'; + markersCanvas.style.cssText = + 'position:absolute;top:0;left:0;pointer-events:none;z-index:6;display:none;'; wrap3.appendChild(markersCanvas); - statusBar = document.createElement('div'); statusBar.style.cssText = 'position:absolute;bottom:4px;right:4px;padding:2px 6px;display:none;'; wrap3.appendChild(statusBar); - _wrapNode = wrap3; + wrapNode = wrap3; } else { - // ── 1D / bar branch ─────────────────────────────────────────────────── + // 1D / bar plotCanvas = document.createElement('canvas'); plotCanvas.tabIndex = 1; - plotCanvas.style.cssText = 'outline:none;cursor:crosshair;display:block;border-radius:2px;'; - - // wrap gives us a positioned container for the absolute canvases + status bar + plotCanvas.style.cssText = + 'outline:none;cursor:crosshair;display:block;border-radius:2px;'; const wrap = document.createElement('div'); wrap.style.cssText = 'position:relative;display:inline-block;line-height:0;'; wrap.appendChild(plotCanvas); - cell.appendChild(wrap); - + outerContainer.appendChild(wrap); overlayCanvas = document.createElement('canvas'); - overlayCanvas.style.cssText = 'position:absolute;top:0;left:0;z-index:5;cursor:crosshair;pointer-events:all;'; + overlayCanvas.style.cssText = + 'position:absolute;top:0;left:0;z-index:5;cursor:crosshair;pointer-events:all;'; wrap.appendChild(overlayCanvas); markersCanvas = document.createElement('canvas'); - markersCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:6;'; + markersCanvas.style.cssText = + 'position:absolute;top:0;left:0;pointer-events:none;z-index:6;'; wrap.appendChild(markersCanvas); - - // Status bar overlays the 1D plot area statusBar = document.createElement('div'); statusBar.style.cssText = 'position:absolute;bottom:4px;right:4px;padding:2px 6px;' + 'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' + 'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;'; wrap.appendChild(statusBar); - _wrapNode = wrap; + wrapNode = wrap; } - const plotCtx = plotCanvas.getContext('2d'); - const ovCtx = overlayCanvas.getContext('2d'); - const mkCtx = markersCanvas.getContext('2d'); - const xCtx = xAxisCanvas ? xAxisCanvas.getContext('2d') : null; - const yCtx = yAxisCanvas ? yAxisCanvas.getContext('2d') : null; + return { plotCanvas, overlayCanvas, markersCanvas, statusBar, + xAxisCanvas, yAxisCanvas, scaleBar, + cbCanvas, cbCtx, plotWrap, wrapNode }; + } - const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; + function _createPanelDOM(id, kind, pw, ph, spec) { + const cell = document.createElement('div'); + cell.style.cssText = + 'position:relative;overflow:visible;line-height:0;' + + 'display:flex;justify-content:center;align-items:flex-start;'; + cell.style.gridRow = `${spec.row_start+1} / ${spec.row_stop+1}`; + cell.style.gridColumn = `${spec.col_start+1} / ${spec.col_stop+1}`; + gridDiv.appendChild(cell); + + const stack = _buildCanvasStack(kind, pw, ph, cell); + + const plotCtx = stack.plotCanvas.getContext('2d'); + const ovCtx = stack.overlayCanvas.getContext('2d'); + const mkCtx = stack.markersCanvas.getContext('2d'); + const xCtx = stack.xAxisCanvas ? stack.xAxisCanvas.getContext('2d') : null; + const yCtx = stack.yAxisCanvas ? stack.yAxisCanvas.getContext('2d') : null; + + const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; - // ── stats overlay (top-left of panel) ──────────────────────────────── - // Positioned absolutely inside the panel's wrap container so it floats - // over the plot area. Visibility is toggled by the display_stats traitlet. const statsDiv = document.createElement('div'); statsDiv.style.cssText = 'position:absolute;top:4px;left:4px;padding:4px 7px;' + 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + 'font-family:monospace;border-radius:4px;pointer-events:none;' + 'white-space:pre;line-height:1.5;z-index:20;display:none;'; - if (_wrapNode) _wrapNode.appendChild(statsDiv); + if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); const p = { id, kind, cell, pw, ph, - plotCanvas, overlayCanvas, markersCanvas, + plotCanvas: stack.plotCanvas, + overlayCanvas: stack.overlayCanvas, + markersCanvas: stack.markersCanvas, plotCtx, ovCtx, mkCtx, - xAxisCanvas, yAxisCanvas, xCtx, yCtx, - scaleBar, statusBar, - statsDiv, // ← per-panel FPS overlay element - frameTimes: [], // ← rolling 60-entry timestamp buffer (performance.now()) + xAxisCanvas: stack.xAxisCanvas, + yAxisCanvas: stack.yAxisCanvas, + xCtx, yCtx, + scaleBar: stack.scaleBar, + statusBar: stack.statusBar, + statsDiv, + frameTimes: [], blitCache, - ovDrag: null, + ovDrag: null, ovDrag2d: null, + isPanning: false, panStart: {}, + state: null, + _hoverSi: -1, _hoverI: -1, + _hovBar: null, + lastWidgetId: null, + mouseX: 0, mouseY: 0, + cbCanvas: stack.cbCanvas, + cbCtx: stack.cbCtx, + sbLine: null, + sbLabel: null, + plotWrap: stack.plotWrap, + }; + panels.set(id, p); + + _resizePanelDOM(id, pw, ph); + _attachPanelEvents(p); + + model.on(`change:panel_${id}_json`, () => { + const p2 = panels.get(id); + if (!p2) return; + try { p2.state = JSON.parse(model.get(`panel_${id}_json`)); } + catch(_) { return; } + p2._hoverSi = -1; p2._hoverI = -1; + _redrawPanel(p2); + }); + + try { p.state = JSON.parse(model.get(`panel_${id}_json`)); } catch(_) {} + _redrawPanel(p); + } + + // ── _createInsetDOM ─────────────────────────────────────────────────────── + // Builds a floating inset panel: + // insetDiv (position:absolute inside insetsContainer) + // ├── titleBar — always visible; click to toggle min/normal + // │ ├── titleSpan + // │ └── maxBtn (⤢ / ⤡) + // └── contentDiv — canvas stack; display:none when minimized + // └── _buildCanvasStack(kind, pw, ph) + function _createInsetDOM(spec) { + const { id, kind, panel_width: pw, panel_height: ph, title, inset_state } = spec; + + const insetDiv = document.createElement('div'); + insetDiv.style.cssText = + 'position:absolute;pointer-events:all;border-radius:4px;overflow:hidden;' + + `box-shadow:0 2px 14px rgba(0,0,0,0.55);border:1px solid ${theme.border};z-index:25;background:${theme.bg};`; + insetsContainer.appendChild(insetDiv); + + // Title bar + const tbBg = theme.dark ? 'rgba(30,32,46,0.97)' : 'rgba(210,213,224,0.97)'; + const titleBar = document.createElement('div'); + titleBar.style.cssText = + `display:flex;align-items:center;height:${INSET_TITLE_H}px;` + + `cursor:pointer;padding:0 5px 0 8px;user-select:none;background:${tbBg};` + + `border-bottom:1px solid ${theme.border};box-sizing:border-box;flex-shrink:0;`; + insetDiv.appendChild(titleBar); + + const titleSpan = document.createElement('span'); + titleSpan.style.cssText = + `flex:1;font-size:11px;font-family:sans-serif;overflow:hidden;` + + `text-overflow:ellipsis;white-space:nowrap;color:${theme.tickText};`; + titleSpan.textContent = title || ''; + titleBar.appendChild(titleSpan); + + + // Content div — wraps the canvas stack + const contentDiv = document.createElement('div'); + contentDiv.style.cssText = + `overflow:hidden;display:${inset_state === 'minimized' ? 'none' : 'block'};`; + insetDiv.appendChild(contentDiv); + + // Canvas stack inside contentDiv + const stack = _buildCanvasStack(kind, pw, ph, contentDiv); + + const plotCtx = stack.plotCanvas.getContext('2d'); + const ovCtx = stack.overlayCanvas.getContext('2d'); + const mkCtx = stack.markersCanvas.getContext('2d'); + const xCtx = stack.xAxisCanvas ? stack.xAxisCanvas.getContext('2d') : null; + const yCtx = stack.yAxisCanvas ? stack.yAxisCanvas.getContext('2d') : null; + + const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; + + const statsDiv = document.createElement('div'); + statsDiv.style.cssText = + 'position:absolute;top:4px;left:4px;padding:4px 7px;' + + 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + + 'font-family:monospace;border-radius:4px;pointer-events:none;' + + 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); + + const p = { + id, kind, pw, ph, + cell: insetDiv, // stale-cleanup compatibility (p.cell.remove()) + isInset: true, insetDiv, contentDiv, titleBar, + insetSpec: spec, + plotCanvas: stack.plotCanvas, + overlayCanvas: stack.overlayCanvas, + markersCanvas: stack.markersCanvas, + plotCtx, ovCtx, mkCtx, + xAxisCanvas: stack.xAxisCanvas, + yAxisCanvas: stack.yAxisCanvas, + xCtx, yCtx, + scaleBar: stack.scaleBar, + statusBar: stack.statusBar, + statsDiv, + frameTimes: [], blitCache, + ovDrag: null, ovDrag2d: null, isPanning: false, panStart: {}, state: null, - _hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none) - _hovBar: null, // {slot,group} of hovered bar, or null - lastWidgetId: null, // id of the last clicked/dragged widget (for on_key Delete etc.) - mouseX: 0, mouseY: 0, // last known canvas-relative cursor position - // 2D extras (null for non-2D panels) - cbCanvas: _p2d ? _p2d.cbCanvas : null, - cbCtx: _p2d ? _p2d.cbCtx : null, - sbLine: null, - sbLabel: null, - plotWrap: _p2d ? _p2d.plotWrap : null, + _hoverSi: -1, _hoverI: -1, + _hovBar: null, + lastWidgetId: null, mouseX: 0, mouseY: 0, + cbCanvas: stack.cbCanvas, + cbCtx: stack.cbCtx, + sbLine: null, sbLabel: null, + plotWrap: stack.plotWrap, }; panels.set(id, p); _resizePanelDOM(id, pw, ph); _attachPanelEvents(p); - // Listen for this panel's trait changes + // Title bar click: toggle normal ↔ minimized + titleBar.addEventListener('click', (e) => { + const cur = p.insetSpec ? p.insetSpec.inset_state : 'normal'; + _applyButtonState(p, cur === 'minimized' ? 'normal' : 'minimized'); + }); + + model.on(`change:panel_${id}_json`, () => { const p2 = panels.get(id); if (!p2) return; @@ -534,11 +665,88 @@ function render({ model, el }) { _redrawPanel(p2); }); - // Initial draw try { p.state = JSON.parse(model.get(`panel_${id}_json`)); } catch(_) {} _redrawPanel(p); } + // Optimistic local state update + Python notification. + function _applyButtonState(p, newState) { + try { + const layout = JSON.parse(model.get('layout_json')); + const spec = (layout.inset_specs || []).find(s => s.id === p.id); + if (spec) { + spec.inset_state = newState; + p.insetSpec = spec; + _applyAllInsetStates(layout); + } + } catch(_) {} + _emitEvent(p.id, 'on_inset_state_change', null, { new_state: newState }); + } + + // ── _applyAllInsetStates ────────────────────────────────────────────────── + // Positions every inset for the given layout snapshot. + // Groups insets by corner, stacks with INSET_GAP spacing. + // Minimized insets contribute only INSET_TITLE_H to the stack height. + // Maximized insets float centred at z-index:45, outside the stack. + function _applyAllInsetStates(layout) { + const insetSpecs = layout.inset_specs || []; + const fw = layout.fig_width || 640; + const fh = layout.fig_height || 480; + + insetsContainer.style.width = fw + 'px'; + insetsContainer.style.height = fh + 'px'; + + // Group by corner, preserving insertion order + const byCorner = {}; + for (const spec of insetSpecs) { + (byCorner[spec.corner] = byCorner[spec.corner] || []).push(spec); + } + + for (const [corner, group] of Object.entries(byCorner)) { + const isBottom = corner.startsWith('bottom'); + const isRight = corner.endsWith('right'); + // Bottom corners: first-added is closest to the corner (stack upward), + // so reverse for the position loop (offset grows from the corner outward). + const walk = isBottom ? [...group].reverse() : group; + let offset = INSET_MARGIN; + + for (const spec of walk) { + const p = panels.get(spec.id); + if (!p || !p.isInset) continue; + + const pw = spec.panel_width; + const ph = spec.panel_height; + const state = spec.inset_state; + + + // Normal or minimized: compute position from corner + const stackH = state === 'minimized' ? INSET_TITLE_H : INSET_TITLE_H + ph; + const left = isRight ? fw - pw - INSET_MARGIN : INSET_MARGIN; + const top = isBottom ? fh - offset - stackH : offset; + + p.insetDiv.style.left = left + 'px'; + p.insetDiv.style.top = top + 'px'; + p.insetDiv.style.width = pw + 'px'; + p.insetDiv.style.height = stackH + 'px'; + p.insetDiv.style.zIndex = '25'; + + if (state === 'minimized') { + p.contentDiv.style.display = 'none'; + } else { + p.contentDiv.style.display = 'block'; + p.contentDiv.style.height = ph + 'px'; + if (p.pw !== pw || p.ph !== ph) { + p.pw = pw; p.ph = ph; + _resizePanelDOM(spec.id, pw, ph); + _redrawPanel(p); + } + } + + offset += stackH + INSET_GAP; + } + } + } + function _resizePanelDOM(id, pw, ph) { const p = panels.get(id); if (!p) return; @@ -552,11 +760,19 @@ function render({ model, el }) { if (p.kind === '2d') { // ── 2D: all elements absolutely positioned within pw×ph container ── - // The image/plot area mirrors 1D's _plotRect1d: - // x: PAD_L → pw-PAD_R, y: PAD_T → ph-PAD_B - const imgX = PAD_L, imgY = PAD_T; - const imgW = Math.max(1, pw - PAD_L - PAD_R); - const imgH = Math.max(1, ph - PAD_T - PAD_B); + // When physical axes are present, reserve PAD gutters for tick labels. + // When there are no axes (plain imshow) use the full canvas area so + // no dead space appears on the left / bottom. + const st = p.state; + const hasPhysAxis = st && (st.is_mesh || st.has_axes) + && st.x_axis && st.x_axis.length >= 2 + && st.y_axis && st.y_axis.length >= 2; + const imgX = hasPhysAxis ? PAD_L : 0; + const imgY = hasPhysAxis ? PAD_T : 0; + const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; + const imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + // Store on panel so event handlers and draw functions don't recompute. + p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; if (p.plotWrap) { p.plotWrap.style.width = pw + 'px'; @@ -576,27 +792,21 @@ function render({ model, el }) { p.markersCanvas.style.top = imgY + 'px'; _sz(p.markersCanvas, p.mkCtx, imgW, imgH); - // Status bar: bottom-left of image area + // Status bar: 4px above bottom of image area, 4px right of left edge if (p.statusBar) { p.statusBar.style.left = (imgX + 4) + 'px'; - p.statusBar.style.bottom = (PAD_B + 4) + 'px'; + p.statusBar.style.bottom = (ph - imgY - imgH + 4) + 'px'; p.statusBar.style.top = ''; } - // Scale bar: bottom-right of image area + // Scale bar: 12px above bottom-right of image area if (p.scaleBar) { - p.scaleBar.style.right = (PAD_R + 12) + 'px'; - p.scaleBar.style.bottom = (PAD_B + 12) + 'px'; + p.scaleBar.style.right = (pw - imgX - imgW + 12) + 'px'; + p.scaleBar.style.bottom = (ph - imgY - imgH + 12) + 'px'; p.scaleBar.style.left = ''; p.scaleBar.style.top = ''; } - const st = p.state; - // Show axis canvases only when the user explicitly provided coordinate - // arrays (has_axes), or for pcolormesh panels (is_mesh, always has edges). - const hasPhysAxis = st && (st.is_mesh || st.has_axes) - && st.x_axis && st.x_axis.length >= 2 - && st.y_axis && st.y_axis.length >= 2; // y-axis: left gutter [0, PAD_T]..[PAD_L, ph-PAD_B] if (p.yAxisCanvas && p.yCtx) { @@ -751,9 +961,10 @@ function render({ model, el }) { // Re-sync axis/histogram canvas visibility whenever state changes _resizePanelDOM(p.id, p.pw, p.ph); const {pw,ph,plotCtx:ctx,blitCache} = p; - // The image canvas occupies the inner plot area, mirroring 1D's _plotRect1d - const imgW = Math.max(1, pw - PAD_L - PAD_R); - const imgH = Math.max(1, ph - PAD_T - PAD_B); + // p.imgW/imgH are set by _resizePanelDOM above (full panel when no axes, + // padded inner area when physical axes are present). + const imgW = p.imgW || Math.max(1, pw - PAD_L - PAD_R); + const imgH = p.imgH || Math.max(1, ph - PAD_T - PAD_B); // Decode base64 image bytes const b64=st.image_b64||''; @@ -800,8 +1011,8 @@ function render({ model, el }) { const scaleX=st.scale_x||0; if(!scaleX||units==='px'){p.scaleBar.style.display='none';return;} - const imgW=Math.max(1,p.pw-PAD_L-PAD_R); - const imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R); + const imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); // Compute bar width in the fit-rect pixel space const zoom=st.zoom||1; @@ -875,7 +1086,7 @@ function render({ model, el }) { if(!vis) return; const cbW=16; - const imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const ctx=p.cbCtx; ctx.clearRect(0,0,cbW,imgH); @@ -913,8 +1124,8 @@ function render({ model, el }) { function _drawAxes2d(p) { const st=p.state; if(!st) return; const {pw,ph} = p; - const imgW = Math.max(1, pw - PAD_L - PAD_R); - const imgH = Math.max(1, ph - PAD_T - PAD_B); + const imgW = p.imgW||Math.max(1, pw - PAD_L - PAD_R); + const imgH = p.imgH||Math.max(1, ph - PAD_T - PAD_B); const xArr=st.x_axis||[], yArr=st.y_axis||[]; const TICK=6; const zoom=st.zoom, cx=st.center_x, cy=st.center_y; @@ -1013,7 +1224,7 @@ function render({ model, el }) { function drawOverlay2d(p) { const st=p.state; if(!st) return; const {pw,ph,ovCtx} = p; - const imgW=Math.max(1,pw-PAD_L-PAD_R), imgH=Math.max(1,ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,ph-PAD_T-PAD_B); ovCtx.clearRect(0,0,imgW,imgH); const widgets=st.overlay_widgets||[]; const scale=_imgScale2d(st,imgW,imgH); @@ -1069,7 +1280,7 @@ function render({ model, el }) { function drawMarkers2d(p, hoverState) { const st=p.state; if(!st) return; const {pw,ph,mkCtx} = p; - const imgW=Math.max(1,pw-PAD_L-PAD_R), imgH=Math.max(1,ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,ph-PAD_T-PAD_B); mkCtx.clearRect(0,0,imgW,imgH); const sets=st.markers||[]; if(!sets.length) return; @@ -2140,7 +2351,7 @@ function render({ model, el }) { overlayCanvas.addEventListener('wheel',(e)=>{ e.preventDefault(); const st=p.state; if(!st) return; - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); // Image point under cursor before zoom change const [anchorX,anchorY]=_canvasToImg2d(mx,my,st,imgW,imgH); @@ -2166,7 +2377,7 @@ function render({ model, el }) { if(e.button!==0) return; const st=p.state; if(!st) return; overlayCanvas.focus(); - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); const hit=_ovHitTest2d(mx, my, p); if(hit){ @@ -2189,7 +2400,7 @@ function render({ model, el }) { } if(!p.isPanning) return; const st=p.state; if(!st) return; - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); const z=st.zoom; const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH); @@ -2214,7 +2425,7 @@ function render({ model, el }) { if(!p.isPanning) return; p.isPanning=false; overlayCanvas.style.cursor='default'; const st=p.state; if(!st) return; - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH); st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); @@ -2226,7 +2437,7 @@ function render({ model, el }) { // Status bar + tooltip + widget hover cursor overlayCanvas.addEventListener('mousemove',(e)=>{ - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); p.mouseX=mx; p.mouseY=my; if(p.ovDrag2d) return; // handled by document mousemove @@ -2284,7 +2495,7 @@ function render({ model, el }) { const st=p.state; if(!st) return; const regKeys=st.registered_keys||[]; if(regKeys.includes(e.key)||regKeys.includes('*')){ - const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); const xArr=st.x_axis||[], yArr=st.y_axis||[]; const iw=st.image_width||1, ih=st.image_height||1; @@ -2497,8 +2708,8 @@ function render({ model, el }) { // 'vertex_N' – polygon vertex N function _ovHitTest2d(mx, my, p) { const st = p.state; if (!st) return null; - const imgW = Math.max(1, p.pw - PAD_L - PAD_R); - const imgH = Math.max(1, p.ph - PAD_T - PAD_B); + const imgW = p.imgW||Math.max(1, p.pw - PAD_L - PAD_R); + const imgH = p.imgH||Math.max(1, p.ph - PAD_T - PAD_B); const widgets = st.overlay_widgets || []; const scale = _imgScale2d(st, imgW, imgH); const HR = 9; // handle grab radius (px) @@ -2579,8 +2790,8 @@ function render({ model, el }) { function _doDrag2d(e, p) { const st = p.state; if (!st) return; - const imgW = Math.max(1, p.pw - PAD_L - PAD_R); - const imgH = Math.max(1, p.ph - PAD_T - PAD_B); + const imgW = p.imgW||Math.max(1, p.pw - PAD_L - PAD_R); + const imgH = p.imgH||Math.max(1, p.ph - PAD_T - PAD_B); const {mx, my} = _clientPos(e, p.overlayCanvas, imgW, imgH); const d = p.ovDrag2d; const s = d.snapW; @@ -2837,6 +3048,25 @@ function render({ model, el }) { gridDiv.style.gridTemplateRows = rowPx.map(px=>px+'px').join(' '); gridDiv.style.width = ''; gridDiv.style.height = ''; + + // CSS-only reposition of insets (no canvas redraw during live drag) + insetsContainer.style.width = nfw + 'px'; + insetsContainer.style.height = nfh + 'px'; + const insetSpecs = layout.inset_specs || []; + for (const spec of insetSpecs) { + const pi = panels.get(spec.id); + if (!pi || !pi.isInset) continue; + const pw = Math.max(64, Math.round(nfw * spec.w_frac)); + const ph = Math.max(64, Math.round(nfh * spec.h_frac)); + pi.pw = pw; pi.ph = ph; + // Reuse _applyAllInsetStates logic inline (CSS only) by temporarily + // patching spec dimensions and calling the function with a fake layout. + spec.panel_width = pw; + spec.panel_height = ph; + } + if (insetSpecs.length) { + _applyAllInsetStates({ ...layout, fig_width: nfw, fig_height: nfh }); + } } // CSS-only resize: move/size elements without clearing canvas buffers. @@ -2852,9 +3082,16 @@ function render({ model, el }) { } if (p.kind === '2d') { - const imgX = PAD_L, imgY = PAD_T; - const imgW = Math.max(1, pw - PAD_L - PAD_R); - const imgH = Math.max(1, ph - PAD_T - PAD_B); + const st = p.state; + const hasPhysAxis = st && (st.is_mesh || st.has_axes) + && st.x_axis && st.x_axis.length >= 2 + && st.y_axis && st.y_axis.length >= 2; + const imgX = hasPhysAxis ? PAD_L : 0; + const imgY = hasPhysAxis ? PAD_T : 0; + const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; + const imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + // Update stored dims so event handlers stay consistent during CSS resize + p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; if (p.plotWrap) { p.plotWrap.style.width=pw+'px'; p.plotWrap.style.height=ph+'px'; } @@ -2866,8 +3103,8 @@ function render({ model, el }) { p.overlayCanvas.style.left = imgX+'px'; p.overlayCanvas.style.top = imgY+'px'; p.markersCanvas.style.left = imgX+'px'; p.markersCanvas.style.top = imgY+'px'; - if (p.statusBar) { p.statusBar.style.left=(imgX+4)+'px'; p.statusBar.style.bottom=(PAD_B+4)+'px'; } - if (p.scaleBar) { p.scaleBar.style.right=(PAD_R+12)+'px'; p.scaleBar.style.bottom=(PAD_B+12)+'px'; } + if (p.statusBar) { p.statusBar.style.left=(imgX+4)+'px'; p.statusBar.style.bottom=(ph-imgY-imgH+4)+'px'; } + if (p.scaleBar) { p.scaleBar.style.right=(pw-imgX-imgW+12)+'px'; p.scaleBar.style.bottom=(ph-imgY-imgH+12)+'px'; } // Axis canvases: just reposition, size handled on full redraw if (p.yAxisCanvas && p.yAxisCanvas.style.display !== 'none') { @@ -2941,6 +3178,10 @@ function render({ model, el }) { const p = panels.get(spec.id); if (p) { spec.panel_width = p.pw; spec.panel_height = p.ph; } } + for (const spec of (layout.inset_specs || [])) { + const pi = panels.get(spec.id); + if (pi) { spec.panel_width = pi.pw; spec.panel_height = pi.ph; } + } model.set('layout_json', JSON.stringify(layout)); } catch(_) {} diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 3581828..54609d2 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -34,8 +34,8 @@ PointWidget as _PointWidget, ) -__all__ = ["GridSpec", "SubplotSpec", "Axes", "Line1D", "Plot1D", "Plot2D", "PlotMesh", - "Plot3D", "PlotBar", "_resample_mesh", "_norm_linestyle"] +__all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", + "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] # --------------------------------------------------------------------------- @@ -3426,6 +3426,125 @@ def __repr__(self) -> str: return f"PlotBar(n={n}, orient={orient!r})" +# --------------------------------------------------------------------------- +# _plot_kind — shared kind string for both layout serialisation and InsetAxes +# --------------------------------------------------------------------------- + +def _plot_kind(plot) -> str: + """Return the JS panel-kind string for a plot object. + + Used in ``Figure._push_layout()`` and ``InsetAxes.__repr__``. + """ + if isinstance(plot, Plot3D): + return "3d" + if isinstance(plot, (Plot2D, PlotMesh)): + return "2d" + if isinstance(plot, PlotBar): + return "bar" + return "1d" + + +# --------------------------------------------------------------------------- +# InsetAxes — floating overlay sub-plot +# --------------------------------------------------------------------------- + +_VALID_CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left") + + +class InsetAxes(Axes): + """A floating inset sub-plot that overlays the main Figure grid. + + Created via :meth:`Figure.add_inset`. Supports the same plot-factory + methods as :class:`Axes` (``imshow``, ``plot``, ``pcolormesh``, etc.). + + The inset is positioned at a corner of the figure and can be minimized + (title bar only), maximized (expanded to fill ~72% of the figure), or + restored to its normal size. + + Parameters + ---------- + fig : Figure + w_frac, h_frac : float + Width and height as fractions of the figure dimensions (0–1). + corner : str, optional + One of ``"top-right"``, ``"top-left"``, ``"bottom-right"``, + ``"bottom-left"``. Default ``"top-right"``. + title : str, optional + Text shown in the inset title bar. Default ``""``. + + Examples + -------- + >>> fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + >>> ax.imshow(data) + >>> inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom") + >>> inset.imshow(data[64:128, 64:128]) + """ + + def __init__(self, fig, w_frac: float, h_frac: float, *, + corner: str = "top-right", title: str = ""): + if corner not in _VALID_CORNERS: + raise ValueError( + f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}" + ) + # Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes + # never occupies a grid cell, only overlays the figure. + super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1)) + self.w_frac = w_frac + self.h_frac = h_frac + self.corner = corner + self.title = title + self._inset_state: str = "normal" + + # ── state API ───────────────────────────────────────────────────────── + + @property + def inset_state(self) -> str: + """Current state: ``"normal"``, ``"minimized"``, or ``"maximized"``.""" + return self._inset_state + + def minimize(self) -> None: + """Collapse the inset to its title bar only (idempotent).""" + if self._inset_state == "minimized": + return + self._inset_state = "minimized" + self._fig._push_layout() + + def maximize(self) -> None: + """Expand the inset to ~72 % of the figure, centred (idempotent).""" + if self._inset_state == "maximized": + return + self._inset_state = "maximized" + self._fig._push_layout() + + def restore(self) -> None: + """Return the inset to its normal corner position (idempotent).""" + if self._inset_state == "normal": + return + self._inset_state = "normal" + self._fig._push_layout() + + # ── internal ────────────────────────────────────────────────────────── + + def _attach(self, plot) -> None: + """Register the plot on this inset via Figure._register_inset.""" + if self._plot is not None: + panel_id = self._plot._id + else: + panel_id = str(_uuid.uuid4())[:8] + plot._id = panel_id + plot._fig = self._fig + self._plot = plot + self._fig._register_inset(self, plot) + + def __repr__(self) -> str: + kind = _plot_kind(self._plot) if self._plot else "empty" + return ( + f"InsetAxes(corner={self.corner!r}, " + f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), " + f"state={self._inset_state!r}, kind={kind!r})" + ) + + diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst index c298626..2e76ec6 100644 --- a/docs/api/figure_plots.rst +++ b/docs/api/figure_plots.rst @@ -14,6 +14,7 @@ Figure Plots :nosignatures: Axes + InsetAxes .. rubric:: Plot Classes @@ -42,6 +43,12 @@ Figure Plots :member-order: bysource :no-index: +.. autoclass:: anyplotlib.figure_plots.InsetAxes + :members: + :show-inheritance: + :member-order: bysource + :no-index: + .. autoclass:: anyplotlib.figure_plots.Plot1D :members: :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 4cde065..4e9952d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,50 @@ dev = [ "playwright>=1.58.0", "pytest>=9.0.2", "scipy>=1.15.3", + "towncrier>=24.0.0", ] +# --------------------------------------------------------------------------- +# Changelog management (towncrier) +# --------------------------------------------------------------------------- +[tool.towncrier] +package = "anyplotlib" +package_dir = "anyplotlib" +directory = "upcoming_changes" +filename = "CHANGELOG.rst" +start_string = ".. towncrier release notes start\n" +issue_format = "`#{issue} `_" +title_format = "{version} ({project_date})" +underlines = ["=", "-", "~"] + +[[tool.towncrier.type]] +directory = "new_feature" +name = "New Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bug Fixes" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecation" +name = "Deprecations" +showcontent = true + +[[tool.towncrier.type]] +directory = "removal" +name = "Removals" +showcontent = true + +[[tool.towncrier.type]] +directory = "doc" +name = "Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "maintenance" +name = "Maintenance" +showcontent = true + diff --git a/tests/baselines/bar_basic.png b/tests/baselines/bar_basic.png index fb77ddf..091c753 100644 Binary files a/tests/baselines/bar_basic.png and b/tests/baselines/bar_basic.png differ diff --git a/tests/baselines/imshow_checkerboard.png b/tests/baselines/imshow_checkerboard.png index 8682d06..73403c2 100644 Binary files a/tests/baselines/imshow_checkerboard.png and b/tests/baselines/imshow_checkerboard.png differ diff --git a/tests/baselines/imshow_gradient.png b/tests/baselines/imshow_gradient.png index 5ef71c9..aa9f6c3 100644 Binary files a/tests/baselines/imshow_gradient.png and b/tests/baselines/imshow_gradient.png differ diff --git a/tests/baselines/imshow_viridis.png b/tests/baselines/imshow_viridis.png index fb8fa12..c6f7923 100644 Binary files a/tests/baselines/imshow_viridis.png and b/tests/baselines/imshow_viridis.png differ diff --git a/tests/baselines/inset_1d.png b/tests/baselines/inset_1d.png new file mode 100644 index 0000000..56899b3 Binary files /dev/null and b/tests/baselines/inset_1d.png differ diff --git a/tests/baselines/inset_maximized.png b/tests/baselines/inset_maximized.png new file mode 100644 index 0000000..74b117a Binary files /dev/null and b/tests/baselines/inset_maximized.png differ diff --git a/tests/baselines/inset_minimized.png b/tests/baselines/inset_minimized.png new file mode 100644 index 0000000..e1d24bb Binary files /dev/null and b/tests/baselines/inset_minimized.png differ diff --git a/tests/baselines/inset_normal_2d.png b/tests/baselines/inset_normal_2d.png new file mode 100644 index 0000000..b7e0c82 Binary files /dev/null and b/tests/baselines/inset_normal_2d.png differ diff --git a/tests/baselines/inset_stacked.png b/tests/baselines/inset_stacked.png new file mode 100644 index 0000000..717d33e Binary files /dev/null and b/tests/baselines/inset_stacked.png differ diff --git a/tests/baselines/inset_stacked_one_minimized.png b/tests/baselines/inset_stacked_one_minimized.png new file mode 100644 index 0000000..7d9e980 Binary files /dev/null and b/tests/baselines/inset_stacked_one_minimized.png differ diff --git a/tests/baselines/pcolormesh_uniform.png b/tests/baselines/pcolormesh_uniform.png index b6dc218..ac0b421 100644 Binary files a/tests/baselines/pcolormesh_uniform.png and b/tests/baselines/pcolormesh_uniform.png differ diff --git a/tests/baselines/plot1d_all_linestyles.png b/tests/baselines/plot1d_all_linestyles.png index b5a1a88..ce5aa9a 100644 Binary files a/tests/baselines/plot1d_all_linestyles.png and b/tests/baselines/plot1d_all_linestyles.png differ diff --git a/tests/baselines/plot1d_alpha.png b/tests/baselines/plot1d_alpha.png index c67d685..1cd9426 100644 Binary files a/tests/baselines/plot1d_alpha.png and b/tests/baselines/plot1d_alpha.png differ diff --git a/tests/baselines/plot1d_dashed.png b/tests/baselines/plot1d_dashed.png index ec45fcc..a47431d 100644 Binary files a/tests/baselines/plot1d_dashed.png and b/tests/baselines/plot1d_dashed.png differ diff --git a/tests/baselines/plot1d_markers.png b/tests/baselines/plot1d_markers.png index 49f9af0..d5d7f62 100644 Binary files a/tests/baselines/plot1d_markers.png and b/tests/baselines/plot1d_markers.png differ diff --git a/tests/baselines/plot1d_multi.png b/tests/baselines/plot1d_multi.png index 9ed5276..16ac31d 100644 Binary files a/tests/baselines/plot1d_multi.png and b/tests/baselines/plot1d_multi.png differ diff --git a/tests/baselines/plot1d_sine.png b/tests/baselines/plot1d_sine.png index bba1243..0a64cb5 100644 Binary files a/tests/baselines/plot1d_sine.png and b/tests/baselines/plot1d_sine.png differ diff --git a/tests/baselines/plot3d_surface.png b/tests/baselines/plot3d_surface.png index c0f9f0c..5f8959f 100644 Binary files a/tests/baselines/plot3d_surface.png and b/tests/baselines/plot3d_surface.png differ diff --git a/tests/baselines/subplots_2x1.png b/tests/baselines/subplots_2x1.png index 08f3182..6cf720c 100644 Binary files a/tests/baselines/subplots_2x1.png and b/tests/baselines/subplots_2x1.png differ diff --git a/tests/test_inset.py b/tests/test_inset.py new file mode 100644 index 0000000..d0435fd --- /dev/null +++ b/tests/test_inset.py @@ -0,0 +1,249 @@ +""" +Tests for InsetAxes — floating overlay inset panels. + +Covers: + - Creation via fig.add_inset() + - layout_json inset_specs content + - All four corners + - Multi-inset stacking (same corner) + - State transitions (minimize / maximize / restore) + - Python-side property inset_state + - _on_event dispatch for on_inset_state_change + - pcolormesh and 1D insets + - Invalid corner raises ValueError + - Figure resize keeps inset fracs correct + - plot._id registered in _plots_map +""" +import json +import numpy as np +import pytest +import anyplotlib as apl +from anyplotlib.figure_plots import InsetAxes + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _make_fig(): + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(np.zeros((64, 64))) + return fig + + +def _inset_spec(fig, plot_id): + layout = json.loads(fig.layout_json) + return next(s for s in layout["inset_specs"] if s["id"] == plot_id) + + +# ── creation ───────────────────────────────────────────────────────────────── + +def test_add_inset_returns_inset_axes(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="top-right", title="T") + assert isinstance(inset, InsetAxes) + + +def test_inset_imshow_returns_plot2d(): + from anyplotlib import Plot2D + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + assert isinstance(plot, Plot2D) + + +def test_inset_plot_returns_plot1d(): + from anyplotlib import Plot1D + fig = _make_fig() + inset = fig.add_inset(0.3, 0.2, corner="bottom-left") + plot = inset.plot(np.zeros(64)) + assert isinstance(plot, Plot1D) + + +def test_inset_pcolormesh_returns_plotmesh(): + from anyplotlib import PlotMesh + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3, corner="bottom-right") + plot = inset.pcolormesh(np.zeros((8, 8)), + np.linspace(0, 1, 9), np.linspace(0, 1, 9)) + assert isinstance(plot, PlotMesh) + + +# ── layout JSON ────────────────────────────────────────────────────────────── + +def test_inset_spec_in_layout_json(): + fig = _make_fig() + inset = fig.add_inset(0.25, 0.25, corner="top-left", title="Phase") + plot = inset.imshow(np.zeros((32, 32))) + + layout = json.loads(fig.layout_json) + assert "inset_specs" in layout + assert len(layout["inset_specs"]) == 1 + spec = layout["inset_specs"][0] + assert spec["id"] == plot._id + assert spec["kind"] == "2d" + assert spec["corner"] == "top-left" + assert spec["title"] == "Phase" + assert spec["w_frac"] == pytest.approx(0.25) + assert spec["h_frac"] == pytest.approx(0.25) + assert spec["inset_state"] == "normal" + + +def test_multiple_insets_in_layout(): + fig = _make_fig() + for corner in ("top-right", "top-left", "bottom-right", "bottom-left"): + inset = fig.add_inset(0.2, 0.2, corner=corner, title=corner) + inset.imshow(np.zeros((16, 16))) + + layout = json.loads(fig.layout_json) + assert len(layout["inset_specs"]) == 4 + corners = {s["corner"] for s in layout["inset_specs"]} + assert corners == {"top-right", "top-left", "bottom-right", "bottom-left"} + + +def test_inset_panel_width_height_computed_from_fracs(): + fig = _make_fig() # 640×480 + inset = fig.add_inset(0.25, 0.30, corner="top-right") + inset.imshow(np.zeros((32, 32))) + + spec = _inset_spec(fig, inset._plot._id) + assert spec["panel_width"] == max(64, round(640 * 0.25)) + assert spec["panel_height"] == max(64, round(480 * 0.30)) + + +def test_inset_registered_in_plots_map(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + assert plot._id in fig._plots_map + assert plot._id in fig._insets_map + + +# ── stacking (same corner) ─────────────────────────────────────────────────── + +def test_two_insets_same_corner(): + fig = _make_fig() + i1 = fig.add_inset(0.25, 0.25, corner="top-right", title="A") + i1.imshow(np.zeros((32, 32))) + i2 = fig.add_inset(0.25, 0.25, corner="top-right", title="B") + i2.imshow(np.zeros((32, 32))) + + layout = json.loads(fig.layout_json) + tr = [s for s in layout["inset_specs"] if s["corner"] == "top-right"] + assert len(tr) == 2 + + +# ── state transitions ──────────────────────────────────────────────────────── + +@pytest.mark.parametrize("method,expected", [ + ("minimize", "minimized"), + ("maximize", "maximized"), + ("restore", "normal"), +]) +def test_state_transition(method, expected): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + + getattr(inset, method)() + assert inset.inset_state == expected + assert _inset_spec(fig, plot._id)["inset_state"] == expected + + +def test_state_idempotent(): + """Calling minimize() twice doesn't trigger an extra _push_layout.""" + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + inset.imshow(np.zeros((32, 32))) + + inset.minimize() + layout_before = fig.layout_json + inset.minimize() # already minimized — should be a no-op + assert fig.layout_json == layout_before + + +def test_restore_from_minimized(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + inset.imshow(np.zeros((32, 32))) + inset.minimize() + inset.restore() + assert inset.inset_state == "normal" + + +def test_maximize_then_restore(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + inset.imshow(np.zeros((32, 32))) + inset.maximize() + assert inset.inset_state == "maximized" + inset.restore() + assert inset.inset_state == "normal" + + +# ── on_inset_state_change event (JS→Python path) ───────────────────────────── + +def test_on_event_inset_state_change(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + + # Simulate a JS button click delivering on_inset_state_change + fig.event_json = json.dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "on_inset_state_change", + "new_state": "minimized", + }) + + assert inset.inset_state == "minimized" + assert _inset_spec(fig, plot._id)["inset_state"] == "minimized" + + +def test_on_event_inset_state_restore_via_event(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + inset.minimize() + + fig.event_json = json.dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "on_inset_state_change", + "new_state": "normal", + }) + assert inset.inset_state == "normal" + + +# ── figure resize updates inset dimensions ─────────────────────────────────── + +def test_resize_updates_inset_panel_size(): + fig = _make_fig() + inset = fig.add_inset(0.3, 0.3) + plot = inset.imshow(np.zeros((32, 32))) + + fig.fig_width = 800 + fig.fig_height = 600 + + spec = _inset_spec(fig, plot._id) + assert spec["panel_width"] == max(64, round(800 * 0.3)) + assert spec["panel_height"] == max(64, round(600 * 0.3)) + + +# ── corner validation ───────────────────────────────────────────────────────── + +def test_invalid_corner_raises(): + fig = _make_fig() + with pytest.raises(ValueError, match="corner"): + fig.add_inset(0.3, 0.3, corner="centre").imshow(np.zeros((4, 4))) + + +# ── repr ───────────────────────────────────────────────────────────────────── + +def test_repr(): + fig = _make_fig() + inset = fig.add_inset(0.28, 0.28, corner="top-right", title="T") + inset.imshow(np.zeros((32, 32))) + r = repr(inset) + assert "InsetAxes" in r + assert "top-right" in r + assert "normal" in r + diff --git a/tests/test_inset_visual.py b/tests/test_inset_visual.py new file mode 100644 index 0000000..599480a --- /dev/null +++ b/tests/test_inset_visual.py @@ -0,0 +1,138 @@ +""" +tests/test_inset_visual.py +========================== + +Pixel-level visual regression tests for InsetAxes. + +Each test: + 1. Builds a deterministic Figure with one or more insets. + 2. Renders it in headless Chromium via ``take_screenshot``. + 3. Compares against a golden PNG in ``tests/baselines/``. + +Generate / refresh baselines:: + + uv run pytest tests/test_inset_visual.py --update-baselines -v + +Normal CI run (fails on regression):: + + uv run pytest tests/test_inset_visual.py -v +""" +from __future__ import annotations + +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl + +BASELINES = pathlib.Path(__file__).parent / "baselines" + + +def _check(name: str, arr: np.ndarray, update: bool) -> None: + from tests._png_utils import decode_png, encode_png, compare_arrays + + path = BASELINES / f"{name}.png" + + if update: + BASELINES.mkdir(exist_ok=True) + path.write_bytes(encode_png(arr)) + pytest.skip(f"Baseline updated: {path.name}") + + if not path.exists(): + pytest.skip( + f"No baseline for {name!r} — run with --update-baselines to create it" + ) + + expected = decode_png(path.read_bytes()) + ok, msg = compare_arrays(arr, expected) + assert ok, f"Visual regression [{name}]: {msg}" + + +def _main_fig(): + """640×480 figure with a grayscale 64×64 imshow — the inset host.""" + rng = np.random.default_rng(0) + fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + ax.imshow(rng.uniform(0.0, 1.0, (64, 64)).astype(np.float32)) + return fig + + +class TestInsetVisual: + """Visual regression tests for the floating inset panel system.""" + + # ── single inset, normal state ───────────────────────────────────────── + + def test_inset_normal_2d(self, take_screenshot, update_baselines): + """2-D inset in top-right corner, normal state.""" + rng = np.random.default_rng(1) + fig = _main_fig() + inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Zoom") + inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32), + cmap="viridis") + arr = take_screenshot(fig) + _check("inset_normal_2d", arr, update_baselines) + + def test_inset_minimized(self, take_screenshot, update_baselines): + """Inset collapsed to title bar only after minimize().""" + rng = np.random.default_rng(2) + fig = _main_fig() + inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Phase") + inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32)) + inset.minimize() + arr = take_screenshot(fig) + _check("inset_minimized", arr, update_baselines) + + def test_inset_maximized(self, take_screenshot, update_baselines): + """Inset expanded to ~72 % of figure after maximize().""" + rng = np.random.default_rng(3) + fig = _main_fig() + inset = fig.add_inset(0.30, 0.30, corner="top-right", title="Detail") + inset.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32), + cmap="inferno") + inset.maximize() + arr = take_screenshot(fig) + _check("inset_maximized", arr, update_baselines) + + # ── two insets stacked in the same corner ────────────────────────────── + + def test_inset_stacked(self, take_screenshot, update_baselines): + """Two insets sharing top-right corner stack with constant gap.""" + rng = np.random.default_rng(4) + fig = _main_fig() + i1 = fig.add_inset(0.28, 0.25, corner="top-right", title="A") + i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32)) + i2 = fig.add_inset(0.28, 0.25, corner="top-right", title="B") + i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32), + cmap="hot") + arr = take_screenshot(fig) + _check("inset_stacked", arr, update_baselines) + + # ── 1-D line inset ───────────────────────────────────────────────────── + + def test_inset_1d(self, take_screenshot, update_baselines): + """1-D line plot inset in bottom-right corner.""" + rng = np.random.default_rng(5) + fig = _main_fig() + inset = fig.add_inset(0.32, 0.22, corner="bottom-right", + title="Profile") + t = np.linspace(0.0, 2 * np.pi, 128) + inset.plot(np.sin(t) + rng.normal(0, 0.05, 128), + color="#4fc3f7", linewidth=1.5) + arr = take_screenshot(fig) + _check("inset_1d", arr, update_baselines) + + # ── stacked with one minimized (restack test) ────────────────────────── + + def test_inset_stacked_one_minimized(self, take_screenshot, update_baselines): + """Two insets in same corner; first minimized — second shifts up.""" + rng = np.random.default_rng(6) + fig = _main_fig() + i1 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Min") + i1.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32)) + i2 = fig.add_inset(0.28, 0.25, corner="bottom-left", title="Normal") + i2.imshow(rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32), + cmap="viridis") + i1.minimize() + arr = take_screenshot(fig) + _check("inset_stacked_one_minimized", arr, update_baselines) + diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 17e637c..33fcb1c 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -867,15 +867,15 @@ def test_point_drag_under_scale(self, interact_page): def _to_page_scaled_2d(ccx: float, ccy: float, scale: float) -> tuple[int, int]: """Canvas coords for a 2D overlayCanvas → page coords under CSS scale. - For 2D panels the overlayCanvas is offset (PAD_L, PAD_T) inside the panel - wrap, which is itself offset GRID_PAD from the page origin:: + For plain imshow panels (no physical axes) the overlayCanvas starts at + (0, 0) inside the panel wrap, so the only offset is GRID_PAD:: - page_x = (GRID_PAD + PAD_L + ccx) * scale - page_y = (GRID_PAD + PAD_T + ccy) * scale + page_x = (GRID_PAD + ccx) * scale + page_y = (GRID_PAD + ccy) * scale """ return ( - int(round((GRID_PAD + PAD_L + ccx) * scale)), - int(round((GRID_PAD + PAD_T + ccy) * scale)), + int(round((GRID_PAD + ccx) * scale)), + int(round((GRID_PAD + ccy) * scale)), ) @@ -967,11 +967,12 @@ def test_2d_crosshair_drag_under_scale(self, interact_page): page = interact_page(fig) s = _inject_scale(page, self._SCALE) - # At zoom=1, center=(0.5,0.5), image 128×128 in panel 400×240: - # imgW=330, imgH=186, fit=min(330/128,186/128)=1.453 - # fr = {x:72, y:0, w:186, h:186} - # canvas pos of (64,64): ccx = 72+(64/128)*186 = 165, ccy = 93 - ccx, ccy = 165.0, 93.0 + # At zoom=1, center=(0.5,0.5), image 128×128 in panel 400×240, + # no physical axes so imgW=400, imgH=240: + # fit = min(400/128, 240/128) = 1.875 + # fr = {x:80, y:0, w:240, h:240} + # canvas pos of (64,64): ccx = 80+(64/128)*240 = 200, ccy = 120 + ccx, ccy = 200.0, 120.0 px_s, py_s = _to_page_scaled_2d(ccx, ccy, s) # Drag 40 canvas pixels to the right and down. diff --git a/upcoming_changes/6.new_feature.rst b/upcoming_changes/6.new_feature.rst new file mode 100644 index 0000000..5e921b3 --- /dev/null +++ b/upcoming_changes/6.new_feature.rst @@ -0,0 +1,6 @@ +Added :class:`~anyplotlib.InsetAxes` — floating overlay sub-plots that sit +above the main figure grid, created via :meth:`~anyplotlib.Figure.add_inset` +and supporting all plot types (:meth:`~anyplotlib.Axes.imshow`, +:meth:`~anyplotlib.Axes.plot`, :meth:`~anyplotlib.Axes.pcolormesh`, etc.) +as well as interactive minimise, maximise, and restore states. + diff --git a/upcoming_changes/README.rst b/upcoming_changes/README.rst new file mode 100644 index 0000000..118a12d --- /dev/null +++ b/upcoming_changes/README.rst @@ -0,0 +1,84 @@ +Filing Change Log Entries +========================= + +anyplotlib uses `towncrier `_ to manage its +changelog. When you open a pull request that should appear in the next release +notes, add a short news **fragment file** to this directory as part of that PR. + +Naming convention +----------------- + +Each fragment is a plain ``.rst`` file named:: + + {PR_number}.{type}.rst + +where ``{PR_number}`` is the GitHub pull-request number and ``{type}`` is one +of: + +================= ============================================================== +Type Use when … +================= ============================================================== +``new_feature`` A user-visible capability has been added. +``bugfix`` A bug has been fixed. +``deprecation`` Something is deprecated and will be removed in a future release. +``removal`` A previously deprecated API has been removed. +``doc`` Documentation improved without any code change. +``maintenance`` Internal / infrastructure change invisible to end users. +================= ============================================================== + +Content guidelines +------------------ + +* **One sentence per file**, written in the **past tense**, from a user's + perspective. +* Cross-reference the relevant class or function with a Sphinx role where + it adds value. +* Do **not** include the PR number in the sentence body — towncrier appends + the link automatically. + +Examples +-------- + +``123.new_feature.rst``:: + + Added :meth:`~anyplotlib.Axes.scatter` for rendering collections of circles + with per-point radii and colours. + +``124.bugfix.rst``:: + + Fixed :meth:`~anyplotlib.Figure.savefig` raising ``ValueError`` when the + ``dpi`` keyword was not supplied explicitly. + +``125.deprecation.rst``:: + + Deprecated the ``color`` keyword on :class:`~anyplotlib.Plot2D`; use + ``facecolor`` instead. ``color`` will be removed in a future release. + +``126.removal.rst``:: + + Removed ``Figure.tight_layout()``, which was deprecated since v0.1.0. + +``127.doc.rst``:: + + Expanded the getting-started guide with a pcolormesh walkthrough and + performance tips. + +``128.maintenance.rst``:: + + Migrated the CI pipeline to ``uv`` for faster, reproducible dependency + installation. + +Previewing the changelog locally +--------------------------------- + +See what the next release notes would look like **without** modifying any +files or consuming any fragments:: + + uvx towncrier build --draft --version 0.x.0 + +To actually build the changelog (done automatically by the +**Prepare Release** workflow — do not run this by hand unless you know what +you are doing):: + + uvx towncrier build --yes --version 0.x.0 +