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
+