diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..0b7e206c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: "3.12"
+ enable-cache: true
+
+ - name: Install dependencies
+ run: uv sync --group dev
+
+ - name: Install Playwright browsers
+ run: uv run playwright install --with-deps chromium
+
+ - name: Run tests with coverage
+ run: uv run pytest
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ files: coverage.xml
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..17a97dab
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,169 @@
+name: Docs
+
+on:
+ push:
+ # Publish to dev/ on every push to main …
+ branches: [main]
+ # … and to a versioned directory on every release tag.
+ tags: ["v*.*.*"]
+ pull_request:
+ branches: [main]
+ # Allow manual re-builds from the Actions tab.
+ workflow_dispatch:
+
+# Only one docs deployment should run at a time to avoid race conditions on
+# the gh-pages branch.
+concurrency:
+ group: docs-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write # needed to push to gh-pages
+
+jobs:
+ # ── Build ──────────────────────────────────────────────────────────────────
+ # Runs on every push and every pull request. Treats warnings as errors so
+ # broken cross-references and bad docstrings are caught before merge.
+ build:
+ name: Build docs
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # ── uv + Python ──────────────────────────────────────────────────────
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: "3.13"
+ enable-cache: true
+
+ # ── Dependencies ─────────────────────────────────────────────────────
+ # Install the package itself plus the [docs] optional-dependency group
+ # (sphinx, pydata-sphinx-theme, sphinx-gallery, pillow, playwright).
+ - name: Install dependencies (with docs extras)
+ run: uv sync --extra docs
+
+ # Playwright ships the Python bindings but NOT the browser binaries.
+ # --with-deps also installs the OS-level shared libraries Chromium needs
+ # (libglib2, libnss3, etc.) on bare Ubuntu runners.
+ - name: Install Playwright browser
+ run: uv run playwright install chromium --with-deps
+
+ # ── Build Pyodide wheel ───────────────────────────────────────────────
+ # Produces docs/_static/wheels/anyplotlib-0.0.0-py3-none-any.whl so the
+ # in-browser Pyodide bridge can install the exact source tree that built
+ # these docs — no PyPI release required.
+ - name: Build Pyodide wheel
+ run: |
+ mkdir -p docs/_static/wheels
+ uv build --wheel --out-dir docs/_static/wheels/
+ # Rename to the stable sentinel name micropip expects for URL installs.
+ cd docs/_static/wheels
+ for f in anyplotlib-*.whl; do
+ [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl
+ done
+
+ # ── Sphinx build ─────────────────────────────────────────────────────
+ # -W turns warnings into errors; --keep-going collects all of them.
+ - name: Build HTML documentation
+ run: |
+ uv run sphinx-build -b html docs build/html -W --keep-going
+
+ # ── Upload built HTML as an artifact so it can be inspected on PRs ──
+ - name: Upload HTML artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: docs-html
+ path: build/html
+ retention-days: 7
+
+ # ── Deploy ─────────────────────────────────────────────────────────────────
+ # Only runs after a successful build on pushes to main or release tags.
+ # Pull requests skip this job entirely.
+ deploy:
+ name: Deploy docs
+ needs: build
+ runs-on: ubuntu-latest
+ # Skip deployment for pull requests.
+ if: github.event_name != 'pull_request'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # ── uv + Python ──────────────────────────────────────────────────────
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: "3.13"
+ enable-cache: true
+
+ # ── Dependencies ─────────────────────────────────────────────────────
+ - name: Install dependencies (with docs extras)
+ run: uv sync --extra docs
+
+ - name: Install Playwright browser
+ run: uv run playwright install chromium --with-deps
+
+ # ── Determine deployment target ──────────────────────────────────────
+ # Release tag (refs/tags/v1.2.3) → destination = "v1.2.3"
+ # Everything else (push to main, manual dispatch) → destination = "dev"
+ - name: Determine deployment directory
+ id: target
+ shell: bash
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ echo "dest_dir=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+ else
+ echo "dest_dir=dev" >> "$GITHUB_OUTPUT"
+ fi
+
+ # ── Build Pyodide wheel ───────────────────────────────────────────────
+ - name: Build Pyodide wheel
+ run: |
+ mkdir -p docs/_static/wheels
+ uv build --wheel --out-dir docs/_static/wheels/
+ cd docs/_static/wheels
+ for f in anyplotlib-*.whl; do
+ [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl
+ done
+
+ # ── Sphinx build ─────────────────────────────────────────────────────
+ - name: Build HTML documentation
+ env:
+ DOCS_VERSION: ${{ steps.target.outputs.dest_dir }}
+ run: |
+ uv run sphinx-build -b html docs build/html -W --keep-going
+
+ # ── Deploy to gh-pages ───────────────────────────────────────────────
+ # keep_files: true preserves all existing directories on the branch so
+ # versioned releases accumulate rather than overwriting each other.
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./build/html
+ destination_dir: ${{ steps.target.outputs.dest_dir }}
+ keep_files: true
+ commit_message: |
+ docs: deploy ${{ steps.target.outputs.dest_dir }} @ ${{ github.sha }}
+
+ # ── Deploy root files (redirect + switcher) ──────────────────────────
+ # Places index.html and switcher.json at the root of gh-pages so the
+ # bare URL redirects to dev/ and the version switcher is always reachable.
+ - name: Deploy root redirect and switcher
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docs/_root
+ destination_dir: .
+ keep_files: true
+ commit_message: |
+ docs: update root redirect and switcher.json @ ${{ github.sha }}
+
diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml
new file mode 100644
index 00000000..cbcabe0f
--- /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 00000000..0ddf3410
--- /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/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..32cbf627
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,80 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Python ${{ matrix.python-version }} / ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
+ exclude:
+ - os: macos-latest
+ python-version: "3.10"
+ - os: macos-latest
+ python-version: "3.11"
+ - os: windows-latest
+ python-version: "3.10"
+ - os: windows-latest
+ python-version: "3.11"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ enable-cache: true
+
+ - name: Install dependencies
+ run: uv sync
+
+ - name: Install Playwright browsers (Linux)
+ if: runner.os == 'Linux'
+ run: uv run playwright install chromium --with-deps
+
+ - name: Install Playwright browsers (macOS / Windows)
+ if: runner.os != 'Linux'
+ run: uv run playwright install chromium
+
+ - name: Run tests
+ run: uv run pytest anyplotlib/tests/ -v --tb=short
+
+ minimum-deps:
+ name: Minimum deps (Python 3.10 / ubuntu)
+ 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.10"
+ enable-cache: true
+
+ - name: Install dependencies at minimum versions
+ run: uv sync --resolution lowest-direct
+
+ - name: Show installed versions
+ run: uv run pip list --format=columns
+
+ - name: Install Playwright browsers
+ run: uv run playwright install chromium --with-deps
+
+ - name: Run tests
+ run: uv run pytest anyplotlib/tests/ -v --tb=short
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..785d9799
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,53 @@
+# Python bytecode / caches
+__pycache__/
+*.py[cod]
+*$py.class
+*.pyo
+
+# Distribution / packaging
+dist/
+build/
+*.egg-info/
+*.egg
+.eggs/
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# Test / coverage artefacts
+.pytest_cache/
+.coverage
+coverage.xml
+htmlcov/
+
+# Jupyter notebooks checkpoints
+.ipynb_checkpoints/
+
+# Sphinx build output
+docs/_build/
+docs/api/generated/
+docs/auto_examples/
+docs/sg_execution_times.rst
+build/html/
+build/doctrees/
+
+# Generated Pyodide wheel (built by workflow / make html — never commit)
+docs/_static/wheels/
+docs/_static/anywidget_config.js
+
+# Editor / IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# macOS
+.DS_Store
+
+# Git worktrees
+.worktrees/
+
+# Generated by Sphinx-Gallery (anywidget iframe HTML) — never commit
+docs/_static/viewer_widgets/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..ed0a6f59
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,127 @@
+# AGENTS.md — anyplotlib Codebase Guide
+
+## Architecture Overview
+
+`anyplotlib` is a Jupyter-compatible interactive plotting library. The key architectural split:
+
+- **`Figure`** (`anyplotlib/figure/_figure.py`) — the only `anywidget.AnyWidget` subclass. Owns all traitlets and is the Python↔JS bridge.
+- **Plot objects** (`plot1d/`, `plot2d/`, `plot3d/`) — `Plot1D`, `PlotBar`, `Plot2D`, `PlotMesh`, `Plot3D` are **plain Python classes**, not widgets. They hold state in `_state` dicts and push to the Figure. Shared behaviour lives in `_base_plot.py` (`_BasePlot`, `_PanelMixin`, `_MarkerMixin`).
+- **`Axes`** (`axes/_axes.py`) — grid-cell container; factory methods (`imshow`, `plot`, `bar`, `pcolormesh`, `plot_surface`, …) create plot objects and attach them.
+- **`figure_esm.js`** — pure-JS canvas renderer (~4,400 lines); all rendering logic lives here. **Read `anyplotlib/FIGURE_ESM.md` first** — it is the section map.
+- **`markers.py`** — static visual overlays (circles, arrows, lines, etc.) with a two-level dict registry: `plot.markers[type][name]`.
+- **`widgets/`** — interactive draggable overlays (`RectangleWidget`, `CrosshairWidget`, etc.) that receive JS position updates.
+- **`callbacks.py`** — event system: `Event` dataclass, `CallbackRegistry` (priority ordering, wildcard, pause/hold), `_EventMixin` (`add_event_handler`).
+- **`embed.py`** — Jupyter-free embedding (Electron / web pages): `figure_state()`, `to_html()`/`save_html()`, `esm_path()`, and `FigureBridge` (transport-agnostic live Python↔JS sync). The JS counterpart is the `mount(el, state, opts)` export in `figure_esm.js`. See `docs/embedding.rst`.
+- **`sphinx_anywidget/`** — Sphinx extension that makes anywidget figures live in docs pages via Pyodide (wheel builder, gallery scraper, `anywidget-figure` directive, `static/anywidget_bridge.js`).
+
+## Package layout
+
+```
+anyplotlib/
+├── __init__.py # public API re-exports
+├── _base_plot.py # _BasePlot, _PanelMixin, _MarkerMixin
+├── _utils.py # b64 encoding, linestyle/colormap helpers
+├── _repr_utils.py # self-contained iframe HTML for non-kernel use
+├── callbacks.py # Event, CallbackRegistry, _EventMixin
+├── markers.py # MarkerRegistry, MarkerGroup
+├── figure_esm.js # the entire JS renderer (see FIGURE_ESM.md)
+├── figure/ # Figure widget, GridSpec/SubplotSpec, subplots()
+├── axes/ # Axes, InsetAxes
+├── plot1d/ # Plot1D, Line1D, PlotBar
+├── plot2d/ # Plot2D, PlotMesh
+├── plot3d/ # Plot3D (surface / scatter / line)
+├── widgets/ # Widget base + 1D/2D widget classes
+├── sphinx_anywidget/ # Sphinx/Pyodide extension (own test suite)
+└── tests/ # main test suite, grouped by area
+```
+
+## Python ↔ JS Data Flow
+
+**Python → JS (push):** Every plot state mutation calls `plot._push()` → `figure._push(panel_id)` → serialises `_state` to JSON → writes to the dynamic traitlet `panel_{id}_json` (tagged `sync=True`) → JS observes and re-renders.
+
+**JS → Python (events/widgets):** JS interaction events (drags, clicks, zoom, keys) come through the `event_json` traitlet → dispatched by `Figure._dispatch_event()` → `Widget._update_from_js()` for widget drags, then `plot.callbacks.fire(event)`.
+
+**Adding state fields:** Add to `_state` in the constructor, include in `to_state_dict()`, and handle in `figure_esm.js`.
+
+## Key Patterns
+
+**`_push()` contract:** Any mutation to a plot's `_state` must end with `self._push()`. Forgetting this means changes won't appear in JS.
+
+**Marker kwargs use matplotlib names** — translated to wire format in `MarkerGroup.to_wire()`:
+```python
+plot.add_circles(offsets, name="g1", facecolors="#f00", edgecolors="#fff", radius=5)
+plot.markers["circles"]["g1"].set(radius=8) # live update
+```
+
+**Widget (interactive overlay) pattern:** handlers register on the widget (or plot) via `add_event_handler` — directly or as a decorator:
+```python
+wid = plot.add_widget("crosshair", cx=64, cy=64)
+
+@wid.add_event_handler("pointer_move") # fires every drag frame — keep fast
+def live(event): readout.value = f"({wid.cx:.1f}, {wid.cy:.1f})"
+
+@wid.add_event_handler("pointer_up") # fires once on release — safe for expensive work
+def done(event): recompute(wid.cx, wid.cy)
+
+@plot.add_event_handler("pointer_settled", ms=400) # dwell-based settling
+def settled(event): ...
+```
+
+**Label sizes and mini-TeX:** all label setters take an optional `fontsize` (CSS px), and label strings support a TeX subset inside `$...$` (superscripts `$10^{-3}$`, subscripts `$E_F$`, Greek `\alpha`, symbols `\times \AA \degree`) parsed at draw time by `_drawTex` in `figure_esm.js` — Python stores strings verbatim:
+```python
+plot.set_xlabel(r"$q_x$ ($\AA^{-1}$)", fontsize=13)
+plot.set_tick_label_size(11)
+```
+
+**`subplots` squeeze behaviour** mirrors matplotlib: `(1,1)` → scalar `Axes`; `(1,N)`/`(N,1)` → 1-D array; `(M,N)` → 2-D array.
+
+**`GridSpec` indexing** mirrors matplotlib exactly, including negative indices, slices, and multi-cell spans — see `tests/test_layouts/test_gridspec.py`.
+
+## Developer Workflows
+
+```bash
+# Install (uses uv)
+uv sync
+uv run playwright install chromium # one-time: browser for rendering tests
+
+# Run the full test suite (pytest testpaths cover both suites)
+uv run pytest
+
+# Run a quick subset without coverage output
+uv run pytest anyplotlib/tests/test_plot1d -q --no-cov
+
+# Build docs (Sphinx Gallery, outputs to build/html/)
+make html
+make clean # wipe build artefacts
+```
+
+Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
+`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `anyplotlib/figure/_figure.py` | `Figure` widget; layout engine; JS↔Python dispatch |
+| `anyplotlib/figure/_gridspec.py` | `GridSpec`, `SubplotSpec` |
+| `anyplotlib/figure/_subplots.py` | `subplots()` factory |
+| `anyplotlib/axes/_axes.py` | `Axes` — plot factory methods |
+| `anyplotlib/figure_esm.js` | All JS canvas rendering (~4,400 lines) |
+| `anyplotlib/FIGURE_ESM.md` | Section map for `figure_esm.js` — read this before editing the JS |
+| `anyplotlib/markers.py` | Static marker collections; `to_wire()` translation |
+| `anyplotlib/widgets/` | Interactive overlay widgets |
+| `anyplotlib/callbacks.py` | `CallbackRegistry`, `Event` dataclass, `_EventMixin` |
+| `anyplotlib/tests/test_interactive/` | Callback + widget tests (good reference for event API) |
+| `anyplotlib/tests/test_layouts/` | GridSpec / sizing pipeline / visual baseline tests |
+| `Examples/` | Gallery examples (files must be named `plot_*.py`) |
+
+## Important Constraints
+
+- The **OO API only** — no `plt.plot()` style. Always create a `Figure` and call methods on `Axes`.
+- Use **`import anyplotlib as apl`** in all examples, docs, and docstrings.
+- Plot objects (`Plot2D` etc.) store all display state in `self._state` (plain dict). Never add traitlets to them.
+- `Figure` adds per-panel traits **dynamically** (`add_traits(panel_{id}_json=...)`); check `has_trait()` before accessing.
+- Colormap LUTs are built via colorcet (`_build_colormap_lut` in `_utils.py`) and serialised as `[[r,g,b], ...]` in `_state["colormap_data"]`; matplotlib is only a fallback and not a dependency.
+- Docs examples in `Examples/` must have a module-level docstring (first lines) for Sphinx Gallery to pick them up; they are executed by `tests/test_examples`.
+- Playwright tests share a session-scoped Chromium fixture (`anyplotlib/conftest.py`); they **error** (not skip) if browsers are missing — run `uv run playwright install chromium` first.
+- When possible stop and ask questions if you're unsure about how something works.
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 00000000..6cdc35c2
--- /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/Examples/Benchmarks/README.rst b/Examples/Benchmarks/README.rst
new file mode 100644
index 00000000..fd905e30
--- /dev/null
+++ b/Examples/Benchmarks/README.rst
@@ -0,0 +1,8 @@
+Benchmarks
+----------
+
+Timing comparisons for the Python-side data-push pipeline in anyplotlib,
+matplotlib, Plotly, and Bokeh. All measurements capture only the
+**Python serialisation cost** — the bottleneck in a live Jupyter session
+where new data must be encoded and dispatched to the browser on every frame.
+
diff --git a/Examples/Benchmarks/plot_benchmark_comparison.py b/Examples/Benchmarks/plot_benchmark_comparison.py
new file mode 100644
index 00000000..07114e67
--- /dev/null
+++ b/Examples/Benchmarks/plot_benchmark_comparison.py
@@ -0,0 +1,559 @@
+"""
+Plot Update Comparison
+======================
+
+There are a couple of different "costs" asscociated with rendering plots and images. There is
+usually a Python-side cost as well as a browser-side rendering cost. We've broken down those
+two costs here comparing different libraries for the first cost. The second is harder to
+measure. We've done it for anyplotlib but doing it for `ipympl`, bokeh and plotly is a
+little more difficult.
+
+* **Python pre-render** — everything that happens in the Python process before
+ bytes reach the browser (``timeit``-measured, no browser needed).
+* **JS canvas render** — the actual canvas paint time measured inside headless
+ Chromium via Playwright (anyplotlib only; see the third and fourth charts).
+
+.. note::
+
+ The Python-side timings are pure-Python ``timeit`` benchmarks — no browser
+ is involved. The JS render timings use Playwright's
+ ``requestAnimationFrame`` loop and ``window._aplTiming`` to measure
+ inter-frame intervals in a real Chromium renderer.
+
+What each Python measurement covers
+-------------------------------------
+
++---------------+---------------------------------------------------------------+
+| Library | What is timed |
++===============+===============================================================+
+| anyplotlib | ``plot.set_data(data)`` — float → uint8 normalise → base64 |
+| | encode → LUT rebuild → state-dict assembly → json.dumps → |
+| | traitlet dispatch to JS renderer. |
++---------------+---------------------------------------------------------------+
+| ipympl | ``im.set_data(data); fig.canvas.draw()`` — fully rasterises |
+| | the figure to an Agg pixel buffer, then encodes it as a PNG |
+| | blob ready for the ipympl comm channel. This is the complete |
+| | Python-side cost before the PNG is sent to the browser. |
++---------------+---------------------------------------------------------------+
+| Plotly | ``fig.data[0].z = data.tolist(); fig.to_json()`` — builds the |
+| | full JSON blob that Plotly.js receives; every float becomes a |
+| | decimal string. Plotly.js WebGL/SVG render is additional. |
++---------------+---------------------------------------------------------------+
+| Bokeh | ``source.data = {"image": [data]}; json_item(p)`` — builds |
+| | the full JSON document patch that Bokeh.js receives. Canvas |
+| | render is additional. |
++---------------+---------------------------------------------------------------+
+
+"""
+# sphinx_gallery_start_ignore
+from __future__ import annotations
+
+import pathlib
+import tempfile
+import timeit
+import warnings
+
+import matplotlib
+matplotlib.use("Agg") # must be set before pyplot import — used for ipympl measurement
+import matplotlib.pyplot as plt
+import numpy as np
+
+# ---------------------------------------------------------------------------
+# Optional library imports — degrade gracefully if not installed
+# ---------------------------------------------------------------------------
+
+try:
+ from playwright.sync_api import sync_playwright as _sync_playwright
+ _HAS_PLAYWRIGHT = True
+except ImportError:
+ _HAS_PLAYWRIGHT = False
+ warnings.warn("Playwright not installed — JS render timing omitted.", stacklevel=1)
+
+try:
+ import plotly.graph_objects as _go
+ _HAS_PLOTLY = True
+except ImportError:
+ _HAS_PLOTLY = False
+ warnings.warn("Plotly not installed — Plotly bars omitted.", stacklevel=1)
+
+try:
+ from bokeh.plotting import figure as _bk_figure
+ from bokeh.models import ColumnDataSource as _CDS
+ from bokeh.embed import json_item as _json_item
+ _HAS_BOKEH = True
+except ImportError:
+ _HAS_BOKEH = False
+ warnings.warn("Bokeh not installed — Bokeh bars omitted.", stacklevel=1)
+
+import anyplotlib as apl
+
+# ---------------------------------------------------------------------------
+# Timing helpers
+# ---------------------------------------------------------------------------
+
+_REPEATS = 5
+_NUMBER = 3
+
+
+def _timeit_min_ms(stmt) -> float:
+ """Return the best (minimum) per-call time in milliseconds."""
+ raw = timeit.repeat(stmt=stmt, number=_NUMBER, repeat=_REPEATS)
+ return min(t / _NUMBER * 1000 for t in raw)
+
+
+# rAF-paced bench loop — mirrors tests/conftest.py _run_bench.
+# Each frame perturbs one state field so the blit-cache is invalidated and
+# the full decode → LUT → render path executes every cycle.
+_JS_BENCH = """
+([panelId, nWarmup, nSamples, field, delta]) =>
+ new Promise((resolve, reject) => {
+ const total = nWarmup + nSamples;
+ let i = 0;
+ function step() {
+ if (i >= total) {
+ resolve(window._aplTiming ? window._aplTiming[panelId] : null);
+ return;
+ }
+ const key = 'panel_' + panelId + '_json';
+ try {
+ const st = JSON.parse(window._aplModel.get(key));
+ st[field] = (st[field] || 0) + delta;
+ window._aplModel.set(key, JSON.stringify(st));
+ } catch(e) { reject(e); return; }
+ if (i === nWarmup - 1) {
+ if (window._aplTiming) delete window._aplTiming[panelId];
+ }
+ i++;
+ requestAnimationFrame(step);
+ }
+ requestAnimationFrame(step);
+ })
+"""
+
+
+def _measure_js_ms_all(pairs, n_warmup=3, n_samples=12):
+ """Measure JS render time for a list of (widget, panel_id, field, delta).
+
+ Opens each widget in a shared headless Chromium session, runs the rAF
+ bench loop, and returns a list of mean_ms values (None on failure).
+ Only called when _HAS_PLAYWRIGHT is True.
+ """
+ from anyplotlib._repr_utils import build_standalone_html
+
+ results_js = []
+ tmp_files = []
+ try:
+ with _sync_playwright() as pw:
+ browser = pw.chromium.launch(
+ headless=True,
+ args=["--no-sandbox", "--disable-setuid-sandbox"],
+ )
+ for pair in pairs:
+ widget, panel_id, field, delta = pair[:4]
+ # Per-pair timeout: large images take longer to decode and paint.
+ # Formula: max(30_000, sz*sz // 200) — scales from 30 s up for 4K+.
+ timeout_ms = pair[4] if len(pair) > 4 else 60_000
+ html = build_standalone_html(widget, resizable=False)
+ html = html.replace(
+ "renderFn({ model, el });",
+ "renderFn({ model, el }); window._aplReady = true;",
+ )
+ html = html.replace(
+ "const model = makeModel(STATE);",
+ "const model = makeModel(STATE);\nwindow._aplModel = model;",
+ )
+ with tempfile.NamedTemporaryFile(
+ suffix=".html", mode="w", encoding="utf-8", delete=False
+ ) as fh:
+ fh.write(html)
+ tmp = pathlib.Path(fh.name)
+ tmp_files.append(tmp)
+ try:
+ page = browser.new_page()
+ page.goto(tmp.as_uri())
+ page.wait_for_function(
+ "() => window._aplReady === true", timeout=timeout_ms
+ )
+ page.evaluate(
+ "() => new Promise(r =>"
+ " requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+ timing = page.evaluate(
+ _JS_BENCH,
+ [panel_id, n_warmup, n_samples, field, delta],
+ )
+ page.close()
+ results_js.append(timing["mean_ms"] if timing else None)
+ except Exception:
+ results_js.append(None)
+ browser.close()
+ finally:
+ for tmp in tmp_files:
+ tmp.unlink(missing_ok=True)
+ return results_js
+
+
+# ---------------------------------------------------------------------------
+# Benchmark configuration
+# ---------------------------------------------------------------------------
+
+_SIZES_2D = [64, 256, 512, 1024, 2048]
+_SIZES_1D = [100, 1_000, 10_000, 100_000]
+
+rng = np.random.default_rng(42)
+
+# Pre-generate fixed frames so array creation is outside the timing loops.
+_frames_2d = {s: rng.uniform(size=(s, s)).astype(np.float32) for s in _SIZES_2D}
+_frames_1d = {n: np.cumsum(rng.standard_normal(n)).astype(np.float32)
+ for n in _SIZES_1D}
+
+_LIBRARIES = ["anyplotlib", "ipympl", "plotly", "bokeh"]
+
+results_2d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES}
+results_1d: dict[str, dict[int, float | None]] = {lib: {} for lib in _LIBRARIES}
+
+# ---------------------------------------------------------------------------
+# 2-D image benchmark
+# ---------------------------------------------------------------------------
+
+for sz in _SIZES_2D:
+ data = _frames_2d[sz]
+
+ # ── anyplotlib: normalize → uint8 → base64 → LUT → json push ────────────
+ _fig_apl, _ax_apl = apl.subplots(1, 1, figsize=(min(sz, 640), min(sz, 640)))
+ _plot_apl = _ax_apl.imshow(data)
+ _update_frames = [rng.uniform(size=(sz, sz)).astype(np.float32)
+ for _ in range(_NUMBER)]
+ _idx = [0]
+
+ def _make_apl_update(plot, frames, idx):
+ def _fn():
+ plot.set_data(frames[idx[0] % len(frames)])
+ idx[0] += 1
+ return _fn
+
+ results_2d["anyplotlib"][sz] = _timeit_min_ms(
+ _make_apl_update(_plot_apl, _update_frames, _idx)
+ )
+
+ # ── ipympl: set_data + full Agg rasterisation (PNG comm pathway) ────────
+ _fig_mpl, _ax_mpl = plt.subplots()
+ _im_mpl = _ax_mpl.imshow(data, cmap="viridis")
+ _canvas_mpl = _fig_mpl.canvas
+ _new_mpl = rng.uniform(size=(sz, sz)).astype(np.float32)
+
+ def _make_mpl_update(im, canvas, new_data):
+ def _fn():
+ im.set_data(new_data)
+ canvas.draw()
+ return _fn
+
+ results_2d["ipympl"][sz] = _timeit_min_ms(
+ _make_mpl_update(_im_mpl, _canvas_mpl, _new_mpl)
+ )
+ plt.close(_fig_mpl)
+
+ # ── Plotly: assign z list + serialise to JSON ────────────────────────────
+ if _HAS_PLOTLY:
+ _pgo_fig = _go.Figure(_go.Heatmap(z=data.tolist()))
+ _new_plotly = rng.uniform(size=(sz, sz)).astype(np.float32).tolist()
+
+ def _make_plotly_update(fig, new_z):
+ def _fn():
+ fig.data[0].z = new_z
+ fig.to_json()
+ return _fn
+
+ results_2d["plotly"][sz] = _timeit_min_ms(
+ _make_plotly_update(_pgo_fig, _new_plotly)
+ )
+ else:
+ results_2d["plotly"][sz] = None
+
+ # ── Bokeh: replace source.data + serialise full document ────────────────
+ if _HAS_BOKEH:
+ _bk_src = _CDS(data={"image": [data], "x": [0], "y": [0],
+ "dw": [sz], "dh": [sz]})
+ _bk_plot = _bk_figure(width=400, height=400)
+ _bk_plot.image(image="image", x="x", y="y", dw="dw", dh="dh",
+ source=_bk_src, palette="Viridis256")
+ _new_bokeh = rng.uniform(size=(sz, sz)).astype(np.float32)
+
+ def _make_bokeh_update(src, new_data, plot, w, h):
+ def _fn():
+ src.data = {"image": [new_data], "x": [0], "y": [0],
+ "dw": [w], "dh": [h]}
+ _json_item(plot)
+ return _fn
+
+ results_2d["bokeh"][sz] = _timeit_min_ms(
+ _make_bokeh_update(_bk_src, _new_bokeh, _bk_plot, sz, sz)
+ )
+ else:
+ results_2d["bokeh"][sz] = None
+
+# ---------------------------------------------------------------------------
+# 1-D line benchmark
+# ---------------------------------------------------------------------------
+
+for n_pts in _SIZES_1D:
+ xs = np.arange(n_pts, dtype=np.float32)
+ ys = _frames_1d[n_pts]
+
+ # ── anyplotlib ───────────────────────────────────────────────────────────
+ _fig_apl1, _ax_apl1 = apl.subplots(1, 1, figsize=(640, 320))
+ _plot_apl1 = _ax_apl1.plot(ys)
+ _new_ys_apl = rng.standard_normal(n_pts).cumsum().astype(np.float32)
+
+ def _make_apl1d(plot, new_y):
+ def _fn(): plot.set_data(new_y)
+ return _fn
+
+ results_1d["anyplotlib"][n_pts] = _timeit_min_ms(
+ _make_apl1d(_plot_apl1, _new_ys_apl)
+ )
+
+ # ── ipympl: set_ydata + full Agg rasterisation (PNG comm pathway) ───────
+ _fig_mpl1, _ax_mpl1 = plt.subplots()
+ (_line_mpl,) = _ax_mpl1.plot(xs, ys)
+ _new_ys_mpl = rng.standard_normal(n_pts).cumsum().astype(np.float32)
+
+ def _make_mpl1d(line, canvas, new_y):
+ def _fn():
+ line.set_ydata(new_y)
+ canvas.draw()
+ return _fn
+
+ results_1d["ipympl"][n_pts] = _timeit_min_ms(
+ _make_mpl1d(_line_mpl, _fig_mpl1.canvas, _new_ys_mpl)
+ )
+ plt.close(_fig_mpl1)
+
+ # ── Plotly ───────────────────────────────────────────────────────────────
+ if _HAS_PLOTLY:
+ _pgo_fig1 = _go.Figure(_go.Scatter(x=xs.tolist(), y=ys.tolist()))
+ _new_ys_plotly = rng.standard_normal(n_pts).cumsum().astype(np.float32).tolist()
+
+ def _make_plotly1d(fig, new_y):
+ def _fn():
+ fig.data[0].y = new_y
+ fig.to_json()
+ return _fn
+
+ results_1d["plotly"][n_pts] = _timeit_min_ms(
+ _make_plotly1d(_pgo_fig1, _new_ys_plotly)
+ )
+ else:
+ results_1d["plotly"][n_pts] = None
+
+ # ── Bokeh ─────────────────────────────────────────────────────────────────
+ if _HAS_BOKEH:
+ _bk_src1 = _CDS(data={"x": xs.tolist(), "y": ys.tolist()})
+ _bk_plot1 = _bk_figure(width=600, height=300)
+ _bk_plot1.line("x", "y", source=_bk_src1)
+ _new_ys_bokeh = rng.standard_normal(n_pts).cumsum().astype(np.float32).tolist()
+
+ def _make_bokeh1d(src, plot, new_x, new_y):
+ def _fn():
+ src.data = {"x": new_x, "y": new_y}
+ _json_item(plot)
+ return _fn
+
+ results_1d["bokeh"][n_pts] = _timeit_min_ms(
+ _make_bokeh1d(_bk_src1, _bk_plot1, xs.tolist(), _new_ys_bokeh)
+ )
+ else:
+ results_1d["bokeh"][n_pts] = None
+
+# ---------------------------------------------------------------------------
+# JS render timing — anyplotlib only (headless Chromium via Playwright)
+# ---------------------------------------------------------------------------
+# _recordFrame() in figure_esm.js timestamps the *start* of every draw call,
+# so the inter-frame interval captured by _aplTiming approximates the full
+# JS render cycle: JSON.parse → uint8 decode → LUT expand → ImageBitmap →
+# ctx.drawImage (2-D) or ctx.lineTo loop (1-D).
+
+results_2d_js: dict[int, float | None] = {s: None for s in _SIZES_2D}
+results_1d_js: dict[int, float | None] = {n: None for n in _SIZES_1D}
+
+if _HAS_PLAYWRIGHT:
+ _pairs_2d_js = []
+ for _sz in _SIZES_2D:
+ _fjs, _ajs = apl.subplots(1, 1, figsize=(min(_sz, 640), min(_sz, 640)))
+ _pjs = _ajs.imshow(_frames_2d[_sz])
+ # Timeout scales with image area: larger images take longer to decode
+ # and paint in Chromium. Formula: max(30 s, sz²/200) ms.
+ _js_timeout = max(30_000, _sz * _sz // 200)
+ _pairs_2d_js.append((_fjs, _pjs._id, "display_min", 1e-4, _js_timeout))
+
+ for _sz, _t in zip(_SIZES_2D, _measure_js_ms_all(_pairs_2d_js)):
+ results_2d_js[_sz] = _t
+
+ _pairs_1d_js = []
+ for _npts in _SIZES_1D:
+ _fjs1, _ajs1 = apl.subplots(1, 1, figsize=(640, 320))
+ _pjs1 = _ajs1.plot(_frames_1d[_npts])
+ _pairs_1d_js.append((_fjs1, _pjs1._id, "view_x0", 1e-4))
+
+ for _npts, _t in zip(_SIZES_1D, _measure_js_ms_all(_pairs_1d_js)):
+ results_1d_js[_npts] = _t
+
+# ---------------------------------------------------------------------------
+# Chart helpers
+# ---------------------------------------------------------------------------
+
+_COLORS = {
+ "anyplotlib": "#1976D2",
+ "ipympl": "#E64A19",
+ "plotly": "#7B1FA2",
+ "bokeh": "#2E7D32",
+}
+
+# Short legend labels shown inside the anyplotlib bar chart.
+_LABELS = {
+ "anyplotlib": "anyplotlib (float→uint8→b64→json→traitlet)",
+ "ipympl": "ipympl (set_data + Agg render → PNG comm)",
+ "plotly": "Plotly (z=list + to_json)",
+ "bokeh": "Bokeh (source.data + json_item)",
+}
+
+
+def _results_to_array(results, sizes):
+ """Build a (N_sizes, N_libs) float array.
+
+ Missing entries (None) become 0.0 — valid JSON, and invisible on a
+ log-scale axis where 0 is clamped to 1e-10 below the visible range.
+ Using NaN would produce bare ``NaN`` tokens that JSON.parse rejects,
+ silently blanking the chart.
+ """
+ rows = []
+ for s in sizes:
+ rows.append([
+ results[lib].get(s) if results[lib].get(s) is not None else 0.0
+ for lib in _LIBRARIES
+ ])
+ return np.array(rows, dtype=float)
+
+# sphinx_gallery_end_ignore
+
+#%%
+# ---------------------------------------------------------------------------
+# 2-D image update (Python pre-render, all four libraries)
+# ---------------------------------------------------------------------------
+
+# sphinx_gallery_start_ignore
+_size_labels_2d = [f"{s}²" for s in _SIZES_2D]
+_heights_2d = _results_to_array(results_2d, _SIZES_2D)
+
+fig2d, ax2d = apl.subplots(1, 1, figsize=(900, 480))
+ax2d.bar(
+ _size_labels_2d,
+ _heights_2d,
+ group_labels=[_LABELS[lib] for lib in _LIBRARIES],
+ group_colors=[_COLORS[lib] for lib in _LIBRARIES],
+ log_scale=True,
+ show_values=False,
+ width=0.85,
+ y_units="ms per call (log scale)",
+ units="Array size",
+)
+fig2d
+# sphinx_gallery_end_ignore
+
+# %%
+# ---------------------------------------------------------------------------
+# 1-D line update (Python pre-render, all four libraries)
+# ---------------------------------------------------------------------------
+
+# sphinx_gallery_start_ignore
+
+_size_labels_1d = [f"{n:,}" for n in _SIZES_1D]
+_heights_1d = _results_to_array(results_1d, _SIZES_1D)
+
+fig1d, ax1d = apl.subplots(1, 1, figsize=(900, 480))
+ax1d.bar(
+ _size_labels_1d,
+ _heights_1d,
+ group_labels=[_LABELS[lib] for lib in _LIBRARIES],
+ group_colors=[_COLORS[lib] for lib in _LIBRARIES],
+ log_scale=True,
+ show_values=False,
+ width=0.85,
+ y_units="ms per call (log scale)",
+ units="Number of points",
+)
+fig1d
+# sphinx_gallery_end_ignore
+
+# %%
+# anyplotlib: Python prep vs JS canvas render
+# -------------------------------------------
+#
+# The two charts above show only the Python-side cost. The charts below add
+# the JS render time for anyplotlib measured inside a real Chromium renderer
+# via Playwright (``window._aplTiming`` populated by ``_recordFrame()`` in
+# ``figure_esm.js``). The sum of both bars is the **total time-to-pixel**
+# for an anyplotlib update.
+#
+# For ipympl, Plotly, and Bokeh the browser render cost is additional but not
+# captured here — measuring it requires running their respective JS engines in
+# a live browser session.
+#
+# .. note::
+#
+# If Playwright is not installed the JS bars are absent (zero height) and
+# a ``UserWarning`` is emitted at import time. Install Playwright
+# (``pip install playwright && playwright install chromium``) to populate
+# the JS timing columns.
+#
+# 2D Image Plotting Costs
+# -----------------------
+
+# sphinx_gallery_start_ignore
+
+_apl_py_2d = np.array([results_2d["anyplotlib"].get(s, 0.0) or 0.0
+ for s in _SIZES_2D])
+_apl_js_2d = np.array([results_2d_js.get(s) or 0.0 for s in _SIZES_2D])
+_breakdown_2d = np.column_stack([_apl_py_2d, _apl_js_2d])
+
+fig_bd2d, ax_bd2d = apl.subplots(1, 1, figsize=(700, 400))
+ax_bd2d.bar(
+ _size_labels_2d,
+ _breakdown_2d,
+ group_labels=["Python prep", "JS canvas render"],
+ group_colors=["#1976D2", "#4CAF50"],
+ log_scale=True,
+ show_values=False,
+ width=0.7,
+ y_units="ms per call (log scale)",
+ units="Array size — anyplotlib 2-D imshow",
+)
+fig_bd2d
+# sphinx_gallery_end_ignore
+
+#%%
+# Scatter Plotting Costs
+# -------------------------
+
+# sphinx_gallery_start_ignore
+_apl_py_1d = np.array([results_1d["anyplotlib"].get(n, 0.0) or 0.0
+ for n in _SIZES_1D])
+_apl_js_1d = np.array([results_1d_js.get(n) or 0.0 for n in _SIZES_1D])
+_breakdown_1d = np.column_stack([_apl_py_1d, _apl_js_1d])
+
+fig_bd1d, ax_bd1d = apl.subplots(1, 1, figsize=(700, 400))
+ax_bd1d.bar(
+ _size_labels_1d,
+ _breakdown_1d,
+ group_labels=["Python prep", "JS canvas render"],
+ group_colors=["#1976D2", "#4CAF50"],
+ log_scale=True,
+ show_values=False,
+ width=0.7,
+ y_units="ms per call (log scale)",
+ units="Number of points — anyplotlib 1-D line",
+)
+fig_bd1d
+# sphinx_gallery_end_ignore
+
+#%%
diff --git a/Examples/Interactive/README.rst b/Examples/Interactive/README.rst
new file mode 100644
index 00000000..3379ea8c
--- /dev/null
+++ b/Examples/Interactive/README.rst
@@ -0,0 +1,6 @@
+Interactive Examples
+====================
+
+Examples that use the callback / event system to connect widget
+interactions to live Python computations.
+
diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py
new file mode 100644
index 00000000..be714995
--- /dev/null
+++ b/Examples/Interactive/plot_3d_spectral_viewer.py
@@ -0,0 +1,229 @@
+"""
+Interactive 3D Spectral Viewer
+==============================
+
+A side-by-side viewer for a 3-D ``(y, x, energy)`` dataset.
+
+* **Left panel** — 2-D projection image (sum over the energy axis).
+ A draggable crosshair ROI selects the pixel whose spectrum appears on
+ the right. Press **i** to switch to an 8 × 8-pixel rectangle ROI
+ that integrates the enclosed area; press **i** again to revert.
+* **Right panel** — 1-D spectrum extracted at the current ROI. Press
+ **s** to overlay an energy-span widget; on release the 2-D image
+ recomputes as the sum over the selected energy window. Press **s**
+ again to remove the span and restore the full-sum image.
+
+**Key bindings**
+
+.. list-table::
+ :header-rows: 1
+ :widths: 10 10 80
+
+ * - Panel
+ - Key
+ - Action
+ * - Image
+ - ``i``
+ - Toggle crosshair / 8x8-px rectangle ROI.
+ Rectangle snaps to the pixel grid and integrates the spectrum live.
+ Press again to revert.
+ * - Spectrum
+ - ``s``
+ - Add/remove an energy-span filter.
+ The 2-D image updates on release to show the sum over the selected
+ energy window. Press again to restore the full-sum image.
+ * - Both
+ - ``r``
+ - Reset zoom / pan.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+# ── Synthetic (NY, NX, NE) dataset ─────────────────────────────────────────
+rng = np.random.default_rng(7)
+
+NY, NX, NE = 64, 64, 256
+energy = np.linspace(100, 900, NE) # physical energy axis (eV)
+
+yy, xx = np.mgrid[0:NY, 0:NX] # spatial index grids
+
+
+def _gauss2d(cx, cy, sigma):
+ return np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2))
+
+
+def _gauss1d(e, mu, sigma):
+ return np.exp(-0.5 * ((e - mu) / sigma) ** 2)
+
+
+# Three Gaussian peaks with spatially-varying amplitudes
+_peaks = [
+ dict(e_mu=280.0, e_sig=18.0, cx=18, cy=18, sig2d=14),
+ dict(e_mu=500.0, e_sig=22.0, cx=46, cy=20, sig2d=13),
+ dict(e_mu=710.0, e_sig=28.0, cx=32, cy=48, sig2d=16),
+]
+
+data = np.zeros((NY, NX, NE), dtype=np.float32)
+for _p in _peaks:
+ _amp = _gauss2d(_p["cx"], _p["cy"], _p["sig2d"]) # (NY, NX)
+ _sp = _gauss1d(energy, _p["e_mu"], _p["e_sig"]) # (NE,)
+ data += (_amp[:, :, np.newaxis] * _sp[np.newaxis, np.newaxis, :]).astype(np.float32)
+
+data += rng.normal(scale=0.02, size=data.shape).astype(np.float32)
+
+img_full = data.sum(axis=-1).astype(float) # full-energy projection (NY, NX)
+
+# Initial ROI centre
+CX0, CY0 = NX // 2, NY // 2
+
+# ── Figure layout ───────────────────────────────────────────────────────────
+fig, (ax_img, ax_spec) = apl.subplots(
+ 1, 2,
+ figsize=(950, 460),
+ help=(
+ "Image — drag crosshair to pick a spectrum\n"
+ " — press i: toggle crosshair / 8×8 rectangle ROI\n"
+ "Spectrum — press s: add/remove energy-span filter"
+ ),
+)
+
+# ── Left: 2-D projection image ──────────────────────────────────────────────
+v_img = ax_img.imshow(img_full)
+v_img.set_colormap("viridis")
+
+# ── Right: 1-D spectrum at initial position ─────────────────────────────────
+v_spec = ax_spec.plot(
+ data[CY0, CX0, :].astype(float),
+ axes=[energy],
+ units="eV",
+ y_units="Intensity (a.u.)",
+ color="#4fc3f7",
+ linewidth=1.5,
+)
+
+# ── Shared state (lists so closures can mutate them) ────────────────────────
+wid = [None] # active 2-D ROI widget
+mode = ["crosshair"] # "crosshair" or "rectangle"
+span_wid = [None] # active energy-span widget (or None)
+_syncing = [False] # echo-loop guard for rectangle snap
+
+ROI_PX = 8 # rectangle ROI fixed size (pixels)
+
+
+# ── Helpers ─────────────────────────────────────────────────────────────────
+
+def _snap_rect(x_raw, y_raw):
+ """Snap top-left corner to the nearest integer pixel, clamped to bounds."""
+ x0 = int(np.clip(round(float(x_raw)), 0, NX - ROI_PX))
+ y0 = int(np.clip(round(float(y_raw)), 0, NY - ROI_PX))
+ return x0, y0
+
+
+def _wire_crosshair(w):
+ """Register pointer_move handler: update spectrum on every drag frame."""
+ @w.add_event_handler("pointer_move")
+ def _ch_moved(event):
+ cx = int(np.clip(round(event.source.cx), 0, NX - 1))
+ cy = int(np.clip(round(event.source.cy), 0, NY - 1))
+ v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy)
+
+
+def _wire_rectangle(w):
+ """Register pointer_move handler: snap widget to grid, integrate 8×8 region live."""
+ @w.add_event_handler("pointer_move")
+ def _rect_moved(event):
+ if _syncing[0]:
+ return
+ _syncing[0] = True
+ try:
+ x0, y0 = _snap_rect(
+ event.source.x,
+ event.source.y,
+ )
+ # Push snapped, fixed-size position back so the widget visually
+ # snaps to the pixel grid and stays exactly 8×8.
+ w.set(x=float(x0), y=float(y0), w=float(ROI_PX), h=float(ROI_PX))
+ spec = data[y0:y0 + ROI_PX, x0:x0 + ROI_PX, :].mean(axis=(0, 1))
+ v_spec.set_data(spec.astype(float), x_axis=energy)
+ finally:
+ _syncing[0] = False
+
+
+# ── Install initial crosshair ────────────────────────────────────────────────
+wid[0] = v_img.add_widget(
+ "crosshair",
+ cx=float(CX0), cy=float(CY0),
+ color="#69f0ae",
+)
+_wire_crosshair(wid[0])
+
+
+# ── "i" — toggle crosshair ↔ 8×8 rectangle ─────────────────────────────────
+@v_img.add_event_handler("key_down")
+def _toggle_roi(event):
+ if event.key != 'i':
+ return
+ cur = wid[0]
+ v_img.remove_widget(cur) # remove old widget (Python ref still valid)
+
+ if mode[0] == "crosshair":
+ # Preserve crosshair centre as rectangle anchor
+ cx_cur = float(cur.get("cx", CX0))
+ cy_cur = float(cur.get("cy", CY0))
+ x0, y0 = _snap_rect(cx_cur - ROI_PX / 2, cy_cur - ROI_PX / 2)
+ new_w = v_img.add_widget(
+ "rectangle",
+ x=float(x0), y=float(y0),
+ w=float(ROI_PX), h=float(ROI_PX),
+ color="#ffeb3b",
+ )
+ _wire_rectangle(new_w)
+ wid[0] = new_w
+ mode[0] = "rectangle"
+ else:
+ # Restore crosshair at centre of old rectangle
+ rx = float(cur.get("x", CX0 - ROI_PX // 2))
+ ry = float(cur.get("y", CY0 - ROI_PX // 2))
+ cx_cur = rx + ROI_PX / 2
+ cy_cur = ry + ROI_PX / 2
+ new_w = v_img.add_widget(
+ "crosshair",
+ cx=float(np.clip(cx_cur, 0, NX - 1)),
+ cy=float(np.clip(cy_cur, 0, NY - 1)),
+ color="#69f0ae",
+ )
+ _wire_crosshair(new_w)
+ wid[0] = new_w
+ mode[0] = "crosshair"
+
+
+# ── "s" (spectrum panel) — add / remove energy-span filter ──────────────────
+@v_spec.add_event_handler("key_down")
+def _toggle_span(event):
+ if event.key != 's':
+ return
+ if span_wid[0] is None:
+ # Place span at 35 %–65 % of the energy range by default
+ e0 = float(energy[int(NE * 0.35)])
+ e1 = float(energy[int(NE * 0.65)])
+ sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043")
+ span_wid[0] = sw
+
+ @sw.add_event_handler("pointer_up")
+ def _span_released(ev):
+ x0_e = ev.source.x0
+ x1_e = ev.source.x1
+ if x0_e > x1_e:
+ x0_e, x1_e = x1_e, x0_e
+ mask = (energy >= x0_e) & (energy <= x1_e)
+ new_img = data[..., mask].sum(axis=-1).astype(float) if mask.any() else img_full
+ v_img.set_data(new_img)
+ else:
+ v_spec.remove_widget(span_wid[0])
+ span_wid[0] = None
+ v_img.set_data(img_full) # restore full-energy projection
+
+
+fig # Interactive
+
diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py
new file mode 100644
index 00000000..8c96fce7
--- /dev/null
+++ b/Examples/Interactive/plot_eels_explorer.py
@@ -0,0 +1,211 @@
+"""
+EELS multi-spectrum explorer.
+==============================
+
+Five synthetic EELS spectra (Carbon-rich, Nitride, Oxide, Silicide,
+Mixed) stacked vertically on a single axis, each with known
+characteristic edges and a power-law background.
+
+**Interaction**
+
+* **Click** a spectrum line — selects it (full opacity; others dim to
+ 25 %).
+* **Dwell 250 ms** — shows eV position and intensity; nearby known
+ edges (C K, N K, O K, Ti L) are annotated.
+* **Double-click** — places a permanent vertical edge marker on the
+ active spectrum.
+* **Delete / Backspace** — removes the most recent marker on the
+ active spectrum.
+* **Tab / Shift+Tab** — cycles the selection forward / backward.
+"""
+import numpy as np
+import anyplotlib as apl
+
+
+# ── synthetic data ─────────────────────────────────────────────────────────────
+
+ENERGY = np.linspace(50, 650, 1200)
+
+KNOWN_EDGES = {"C K": 284.0, "N K": 401.0, "O K": 532.0, "Ti L": 456.0}
+
+_SPECTRUM_DEFS = [
+ {"name": "Carbon-rich", "color": "#4fc3f7", "edges": [("C K", 284, 0.6)]},
+ {"name": "Nitride", "color": "#aed581", "edges": [("N K", 401, 0.5)]},
+ {"name": "Oxide", "color": "#ff8a65", "edges": [("O K", 532, 0.7)]},
+ {"name": "Silicide", "color": "#ba68c8", "edges": [("Si L", 99, 0.3)]},
+ {"name": "Mixed", "color": "#fff176", "edges": [("C K", 284, 0.2), ("O K", 532, 0.15)]},
+]
+
+
+def _power_law_bg(E, A=1e4, r=3.5):
+ return A * E ** (-r)
+
+
+def _edge_onset(E, edge_ev, amplitude, width=20.0, decay=80.0):
+ onset = amplitude * (np.arctan((E - edge_ev) / (width / 6)) / np.pi + 0.5)
+ envelope = np.exp(-np.clip(E - edge_ev, 0, None) / decay)
+ return onset * envelope
+
+
+def _make_spectrum(rng, defn, offset_y):
+ E = ENERGY
+ y = _power_law_bg(E)
+ for _, edge_ev, amp_frac in defn["edges"]:
+ peak = y.max() * amp_frac
+ y += _edge_onset(E, edge_ev, peak)
+ y += rng.normal(0, y.max() * 0.005, size=len(E))
+ y = np.clip(y, 0, None)
+ y = y / y.max()
+ return y + offset_y
+
+
+rng = np.random.default_rng(7)
+spectra_y = []
+offset = 0.0
+for defn in _SPECTRUM_DEFS:
+ y = _make_spectrum(rng, defn, offset)
+ spectra_y.append(y)
+ offset += 1.2 * (y - offset).max()
+
+
+# ── helpers ────────────────────────────────────────────────────────────────────
+
+def _safe_remove(plot, marker_type: str, name: str) -> None:
+ try:
+ plot.remove_marker(marker_type, name)
+ except KeyError:
+ pass
+
+
+# ── figure ─────────────────────────────────────────────────────────────────────
+
+# spectrum 0 is the primary line; spectra 1-4 are overlay lines
+fig, ax = apl.subplots(1, 1, figsize=(800, 500))
+plot = ax.plot(spectra_y[0], axes=[ENERGY], color=_SPECTRUM_DEFS[0]["color"], linewidth=2.5)
+
+# overlay_lines[i] is the Line1D handle for spectrum i (None for the primary)
+overlay_lines = []
+for i in range(1, len(_SPECTRUM_DEFS)):
+ defn = _SPECTRUM_DEFS[i]
+ line = plot.add_line(spectra_y[i], x_axis=ENERGY, color=defn["color"], linewidth=1.0)
+ overlay_lines.append(line)
+
+# spectra index → Line1D (or None for primary)
+# lines[0] == None means "primary line", lines[1..] == Line1D handles
+line_handles = [None] + overlay_lines # len == len(_SPECTRUM_DEFS)
+
+active_idx: int = 0
+markers_per_spectrum: list[list[str]] = [[] for _ in _SPECTRUM_DEFS]
+_marker_counter = [0]
+
+info_label_mg = plot.add_texts(
+ offsets=np.array([[ENERGY[600], spectra_y[0][600]]]),
+ texts=[""],
+ name="info_label",
+ color="#00e5ff",
+ fontsize=11,
+)
+
+
+# ── selection helpers ───────────────────────────────────────────────────────────
+
+def _set_overlay_line_props(lid: str, linewidth: float, alpha: float) -> None:
+ """Directly mutate an overlay line's entry in plot._state and push."""
+ for entry in plot._state["extra_lines"]:
+ if entry["id"] == lid:
+ entry["linewidth"] = float(linewidth)
+ entry["alpha"] = float(alpha)
+ break
+ plot._push()
+
+
+def _apply_selection(new_idx: int) -> None:
+ global active_idx
+ active_idx = new_idx
+ for i, handle in enumerate(line_handles):
+ if i == active_idx:
+ lw, alpha = 2.5, 1.0
+ else:
+ lw, alpha = 1.0, 0.25
+ if handle is None:
+ # primary line — use Plot1D setters
+ plot.set_linewidth(lw)
+ plot.set_alpha(alpha)
+ else:
+ _set_overlay_line_props(handle._lid, lw, alpha)
+ print(f"Selected: {_SPECTRUM_DEFS[active_idx]['name']}")
+
+
+_apply_selection(0)
+
+
+# ── event handlers ─────────────────────────────────────────────────────────────
+
+def _make_line_handler(idx: int):
+ def _handler(event) -> None:
+ _apply_selection(idx)
+ return _handler
+
+
+# primary line click handler — line_id is None for the primary
+plot.line.add_event_handler(_make_line_handler(0), "pointer_down")
+
+# overlay line click handlers
+for i, handle in enumerate(overlay_lines, start=1):
+ handle.add_event_handler(_make_line_handler(i), "pointer_down")
+
+
+def _on_settled(event) -> None:
+ if event.xdata is None:
+ return
+ ev = event.xdata
+ intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx]))
+ label = f"eV: {ev:.1f} I: {intensity:.3f}"
+ for edge_name, edge_ev in KNOWN_EDGES.items():
+ if abs(ev - edge_ev) < 15:
+ label += f"\n~ {edge_name}-edge"
+ y_pos = intensity + 0.05
+ plot.markers["texts"]["info_label"].set(
+ offsets=np.array([[ev, y_pos]]),
+ texts=[label],
+ )
+
+
+def _on_double_click(event) -> None:
+ ev = event.xdata
+ _marker_counter[0] += 1
+ name = f"edge_{active_idx}_{_marker_counter[0]}"
+ plot.add_vlines([ev], name=name)
+ markers_per_spectrum[active_idx].append(name)
+ print(f"Edge marker placed at {ev:.1f} eV on '{_SPECTRUM_DEFS[active_idx]['name']}'")
+
+
+def _on_key(event) -> None:
+ global active_idx
+ if event.key in ("Delete", "Backspace"):
+ if not markers_per_spectrum[active_idx]:
+ return
+ name = markers_per_spectrum[active_idx].pop()
+ _safe_remove(plot, "vlines", name)
+ elif event.key == "Tab":
+ n = len(_SPECTRUM_DEFS)
+ if "shift" in event.modifiers:
+ new_idx = (active_idx - 1) % n
+ else:
+ new_idx = (active_idx + 1) % n
+ _apply_selection(new_idx)
+
+
+plot.add_event_handler(_on_settled, "pointer_settled", ms=250)
+plot.add_event_handler(_on_double_click, "double_click")
+plot.add_event_handler(_on_key, "key_down")
+
+fig.set_help(
+ "Click a spectrum: select it\n"
+ "Dwell 250 ms: inspect eV + intensity\n"
+ "Double-click: place edge marker\n"
+ "Delete / Backspace: remove last marker\n"
+ "Tab / Shift+Tab: cycle selection"
+)
+
+fig # interactive
diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py
new file mode 100644
index 00000000..15a47b03
--- /dev/null
+++ b/Examples/Interactive/plot_interactive_fft.py
@@ -0,0 +1,180 @@
+"""
+Interactive FFT ROI
+===================
+
+A draggable rectangle widget on a real-space image drives a live 2-D FFT
+of the selected region, displayed in a side-by-side panel.
+
+**How it works**
+
+* The left panel shows a synthetic real-space image (a periodic lattice with
+ noise, similar to an atomic-resolution STEM image).
+* A yellow rectangle widget marks the region-of-interest (ROI).
+* Whenever the ROI is moved or resized the ``pointer_up`` event handler
+ re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a Hann
+ window to reduce edge ringing, takes the log-magnitude, and pushes the
+ result into the right panel with :meth:`~anyplotlib.plot2d.Plot2D.update`.
+* A second ``pointer_move`` event handler updates a lightweight text
+ readout (ROI size in pixels) on every drag frame without re-running
+ the FFT.
+
+**Interaction**
+
+* Drag the rectangle body to move the ROI.
+* Drag any corner handle to resize it.
+* The FFT panel refreshes automatically on mouse-release.
+
+.. note::
+ The ``pointer_up`` / ``pointer_move`` event handlers are pure Python —
+ no kernel restart is needed after editing them.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+# ── Synthetic real-space image ────────────────────────────────────────────────
+# Periodic lattice (two overlapping sinusoidal gratings) + Gaussian envelope
+# + shot noise. Mimics a crystalline region in an electron-microscopy image.
+
+N = 256 # image size (pixels)
+rng = np.random.default_rng(42)
+
+x = np.arange(N)
+XX, YY = np.meshgrid(x, x)
+
+# Two lattice periodicities (pixels)
+a1, a2 = 22, 14
+theta = np.deg2rad(30)
+
+lattice = (
+ np.cos(2 * np.pi * (XX * np.cos(theta) + YY * np.sin(theta)) / a1)
+ + 0.6 * np.cos(2 * np.pi * (XX * np.cos(theta + np.pi / 3)
+ + YY * np.sin(theta + np.pi / 3)) / a2)
+)
+
+# Gaussian envelope (brighter in centre)
+cx, cy = N // 2, N // 2
+gauss = np.exp(-((XX - cx) ** 2 + (YY - cy) ** 2) / (2 * (N * 0.35) ** 2))
+
+image = gauss * lattice + rng.normal(scale=0.08, size=(N, N))
+
+# Normalise to [0, 1]
+image = (image - image.min()) / (image.max() - image.min())
+
+# Physical axis: 0.1 Å / pixel
+scale = 0.1 # Å per pixel
+xy_px = np.arange(N) * scale # physical axis in Å
+
+# ── Figure layout: real-space (left) | FFT (right) ───────────────────────────
+fig, (ax_real, ax_fft) = apl.subplots(
+ 1, 2,
+ figsize=(900, 460),
+ sharex=False,
+ sharey=False,
+)
+
+# ── Left panel: real-space image ──────────────────────────────────────────────
+v_real = ax_real.imshow(image, axes=[xy_px, xy_px], units="Å")
+v_real.set_colormap("gray")
+
+# Initial ROI: centred, 64 × 64 px
+ROI_W, ROI_H = 64, 64
+roi_x0 = (N - ROI_W) // 2 # pixel coords (top-left corner)
+roi_y0 = (N - ROI_H) // 2
+
+wid = v_real.add_widget(
+ "rectangle",
+ color="#ffeb3b",
+ x=float(roi_x0),
+ y=float(roi_y0),
+ w=float(ROI_W),
+ h=float(ROI_H),
+)
+
+# ── Right panel: FFT magnitude ────────────────────────────────────────────────
+def _compute_fft(img_full, x0, y0, w, h):
+ """Crop, window and FFT a region of *img_full*.
+
+ Parameters
+ ----------
+ img_full : ndarray, shape (N, N) – full real-space image (float)
+ x0, y0 : float – top-left corner of rectangle in pixel coords
+ w, h : float – width and height in pixels
+
+ Returns
+ -------
+ log_mag : ndarray – log10(1 + |FFT|), shifted so DC is at centre
+ freq_x : ndarray – spatial-frequency axis (1/Å), shape (w_int,)
+ freq_y : ndarray – spatial-frequency axis (1/Å), shape (h_int,)
+ """
+ ih, iw = img_full.shape
+
+ # Clamp ROI to image bounds
+ x0i = max(0, int(round(x0)))
+ y0i = max(0, int(round(y0)))
+ x1i = min(iw, x0i + max(1, int(round(w))))
+ y1i = min(ih, y0i + max(1, int(round(h))))
+
+ crop = img_full[y0i:y1i, x0i:x1i].copy()
+ ch, cw = crop.shape
+ if ch < 2 or cw < 2:
+ # ROI too small — return a blank placeholder
+ blank = np.zeros((4, 4))
+ f = np.fft.fftfreq(4, d=scale)
+ return blank, f, f
+
+ # Hann window to suppress edge ringing
+ win_y = np.hanning(ch)
+ win_x = np.hanning(cw)
+ crop *= win_y[:, None] * win_x[None, :]
+
+ # 2-D FFT → log magnitude, DC centred
+ fft2 = np.fft.fftshift(np.fft.fft2(crop))
+ log_mag = np.log1p(np.abs(fft2))
+
+ # Spatial-frequency axes (cycles per Å)
+ freq_x = np.fft.fftshift(np.fft.fftfreq(cw, d=scale))
+ freq_y = np.fft.fftshift(np.fft.fftfreq(ch, d=scale))
+
+ return log_mag, freq_x, freq_y
+
+
+# Compute initial FFT and display it
+_fft_init, _fx_init, _fy_init = _compute_fft(image, roi_x0, roi_y0, ROI_W, ROI_H)
+v_fft = ax_fft.imshow(_fft_init, axes=[_fx_init, _fy_init], units="1/Å")
+v_fft.set_colormap("inferno")
+
+# ── Callbacks ─────────────────────────────────────────────────────────────────
+
+@wid.add_event_handler("pointer_move")
+def _roi_dragging(event):
+ """Fires on every drag frame — highlight rectangle while dragging."""
+ # Cheaply pulse the widget colour to give live drag feedback.
+ for w in v_real._state["overlay_widgets"]:
+ if w["id"] == wid._id:
+ w["color"] = "#ff9800" # orange while dragging
+ break
+ v_real._push()
+
+
+@wid.add_event_handler("pointer_up")
+def _roi_released(event):
+ """Fires once on mouse-up — recompute and push the full FFT."""
+ x0 = event.source.x
+ y0 = event.source.y
+ w = event.source.w
+ h = event.source.h
+
+ # Restore widget colour to yellow
+ for widget in v_real._state["overlay_widgets"]:
+ if widget["id"] == wid._id:
+ widget["color"] = "#ffeb3b"
+ break
+
+ log_mag, freq_x, freq_y = _compute_fft(image, x0, y0, w, h)
+
+ # Push updated FFT into the right panel
+ v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5")
+
+
+fig # Interactive
diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py
new file mode 100644
index 00000000..02916a5d
--- /dev/null
+++ b/Examples/Interactive/plot_interactive_fitting.py
@@ -0,0 +1,297 @@
+"""
+Interactive 1-D Gaussian Fitting
+=================================
+
+A noisy composite signal built from two Gaussians is displayed. Two
+additional overlay lines show the individual **component** curves and a
+white **sum** curve that always equals the current manual model.
+
+**Interaction**
+
+Click any coloured component line to reveal its control widgets:
+
+* **Circular handle** — drag to move the peak centre (μ) and amplitude (A).
+* **Shaded range** — drag either edge to widen or narrow the width (σ).
+
+The sum curve updates on every drag frame.
+Press **f** (with the plot canvas focused) to run a least-squares fit.
+The components — and all active widgets — will snap to the fitted values,
+and the sum curve will jump to the optimal fit.
+Click a component line again to hide its widgets.
+"""
+# Packages required when running interactively in Pyodide (docs live mode).
+_PYODIDE_PACKAGES = ["scipy"]
+
+import numpy as np
+from scipy.optimize import curve_fit
+import anyplotlib as apl
+
+# ── Gaussian helpers ───────────────────────────────────────────────────────
+
+def gaussian(x, amp, mu, sigma):
+ return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
+
+# Half-width at half-maximum = sigma * _FWHM_K (full FWHM = 2 * sigma * _FWHM_K)
+_FWHM_K = np.sqrt(2.0 * np.log(2.0))
+
+# ── Data ───────────────────────────────────────────────────────────────────
+
+x = np.linspace(0, 10, 500)
+
+TRUE_P = [
+ dict(amp=1.0, mu=3.2, sigma=0.55),
+ dict(amp=0.75, mu=6.8, sigma=0.80),
+]
+COLORS = ["#ff6b6b", "#69db7c"]
+
+rng = np.random.default_rng(42)
+signal = sum(gaussian(x, **p) for p in TRUE_P) + rng.normal(0, 0.03, len(x))
+
+# Initial component guesses (slightly off from truth)
+INIT_P = [
+ dict(amp=1.0, mu=3.0, sigma=0.6),
+ dict(amp=0.7, mu=7.0, sigma=0.9),
+]
+
+# ── Figure ─────────────────────────────────────────────────────────────────
+
+fig, ax = apl.subplots(1, 1, figsize=(720, 380),
+ help="Click a coloured line → show/hide its widgets\n"
+ "Drag circle handle → move peak center (μ) and amplitude (A)\n"
+ "Drag range edge → widen / narrow the width (σ)\n"
+ "press: f → run least-squares fit")
+plot = ax.plot(signal, axes=[x], color="#adb5bd", linewidth=1.5,
+ alpha=0.6, label="data")
+#
+# Live sum of all components — this IS the fit after pressing 'f'
+sum_line = plot.add_line(
+ sum(gaussian(x, **p) for p in INIT_P), x_axis=x,
+ color="#e0e0e0", linewidth=1.5, linestyle="dashed", label="sum",
+)
+
+comp_lines = [
+ plot.add_line(gaussian(x, **p), x_axis=x,
+ color=c, linewidth=2.0,
+ label=f"comp {i+1}")
+ for i, (p, c) in enumerate(zip(INIT_P, COLORS))
+]
+
+
+# ── GaussianComponent ──────────────────────────────────────────────────────
+
+class GaussianComponent:
+ """Manages a PointWidget (peak) + RangeWidget (σ) for one component.
+
+ Assign ``.model`` after constructing the ``Model`` so the component
+ can notify it on every drag frame.
+ """
+
+ def __init__(self, line, p, color):
+ self.line = line
+ self.amp = p["amp"]
+ self.mu = p["mu"]
+ self.sigma = p["sigma"]
+ self.color = color
+ self.model = None # injected after Model is constructed
+ self._active = False
+ self._syncing = False # guard against callback loops
+ self._pt = None # PointWidget — created once on first toggle
+ self._rng_w = None # RangeWidget
+
+ def component_y(self):
+ return gaussian(x, self.amp, self.mu, self.sigma)
+
+ def toggle(self):
+ if self._active:
+ self._pt.hide()
+ self._rng_w.hide()
+ self._active = False
+ else:
+ if self._pt is None:
+ self._pt = plot.add_point_widget(self.mu, self.amp,
+ color=self.color,
+ show_crosshair=False)
+ self._rng_w = plot.add_range_widget(
+ self.mu - self.sigma * _FWHM_K,
+ self.mu + self.sigma * _FWHM_K,
+ y=self.amp / 2.0,
+ color=self.color,
+ style="fwhm",
+ )
+ self._wire()
+ else:
+ self._pt.show()
+ self._rng_w.show()
+ self._active = True
+
+ def _wire(self):
+ @self._pt.add_event_handler("pointer_move")
+ def _peak_moved(event):
+ if self._syncing:
+ return
+ self._syncing = True
+ try:
+ self.amp = event.source.y
+ self.mu = event.source.x
+ self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K,
+ x1=self.mu + self.sigma * _FWHM_K,
+ y=self.amp / 2.0)
+ self.line.set_data(self.component_y())
+ if self.model:
+ self.model.update()
+ finally:
+ self._syncing = False
+
+ @self._rng_w.add_event_handler("pointer_move")
+ def _range_moved(event):
+ if self._syncing:
+ return
+ self._syncing = True
+ try:
+ x0, x1 = event.source.x0, event.source.x1
+ self.mu = (x0 + x1) / 2.0
+ self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K)
+ self._pt.set(x=self.mu)
+ self.line.set_data(self.component_y())
+ if self.model:
+ self.model.update()
+ finally:
+ self._syncing = False
+
+ def snap(self, amp: float, mu: float, sigma: float) -> None:
+ """Update parameters and snap **all** widgets to the new values.
+
+ Creates and shows the point and FWHM range widgets if they do not
+ exist yet (so pressing **f** always reveals the fitted widths), then
+ updates their positions. Uses the ``_syncing`` guard so widget
+ callbacks do not fire during the programmatic update.
+ """
+ self._syncing = True
+ try:
+ self.amp = amp
+ self.mu = mu
+ self.sigma = sigma
+ self.line.set_data(self.component_y())
+ if self._pt is None:
+ # First fit — create widgets at the fitted position and show them.
+ self._pt = plot.add_point_widget(self.mu, self.amp,
+ color=self.color,
+ show_crosshair=False)
+ self._rng_w = plot.add_range_widget(
+ self.mu - self.sigma * _FWHM_K,
+ self.mu + self.sigma * _FWHM_K,
+ y=self.amp / 2.0,
+ color=self.color,
+ style="fwhm",
+ )
+ self._wire()
+ self._active = True
+ else:
+ # Widgets already exist — move them to the new fitted position.
+ self._pt.set(x=self.mu, y=self.amp)
+ self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K,
+ x1=self.mu + self.sigma * _FWHM_K,
+ y=self.amp / 2.0)
+ # If the user had hidden the widgets, bring them back.
+ if not self._active:
+ self._pt.show()
+ self._rng_w.show()
+ self._active = True
+ finally:
+ self._syncing = False
+
+# ── Model ──────────────────────────────────────────────────────────────────
+
+class Model:
+ """A list of GaussianComponents with a live sum line.
+
+ ``update()`` redraws the sum line from the current component state and
+ is called on every drag frame.
+
+ ``fit()`` runs a least-squares fit, snaps every component (and its
+ widgets) to the optimal parameters, then calls ``update()`` so the sum
+ line jumps to the best fit. It is also triggered by pressing **f**.
+
+ Parameters
+ ----------
+ components : list[GaussianComponent]
+ sum_line : Line1D
+ Always-live manual-sum / fit-result overlay.
+ x_data, y_data : ndarray
+ Observed signal to fit against.
+ """
+
+ def __init__(self, components, sum_line, x_data, y_data):
+ self.components = list(components)
+ self.sum_line = sum_line
+ self.x_data = x_data
+ self.y_data = y_data
+
+ def update(self):
+ """Redraw the sum line as the manual sum of all components."""
+ self.sum_line.set_data(
+ sum(c.component_y() for c in self.components)
+ )
+
+ def fit(self):
+ """Least-squares fit; snaps components and FWHM widgets to the result.
+
+ Builds a generic n-Gaussian model from the component list and uses
+ their current state as the initial guess. On success every component
+ snaps to the fitted (amp, μ, σ): the component line, the peak handle,
+ **and** the FWHM range widget are all moved to the optimal values.
+ If a component's widgets have not been shown yet they are created and
+ revealed automatically. The sum line redraws as the best fit.
+ On failure the components are left unchanged.
+ """
+ n = len(self.components)
+ p0 = [v for c in self.components for v in (c.amp, c.mu, c.sigma)]
+ lo = [v for c in self.components for v in (0, self.x_data[0], 1e-3)]
+ hi = [v for c in self.components
+ for v in (np.inf, self.x_data[-1],
+ self.x_data[-1] - self.x_data[0])]
+
+ def _model_fn(x, *params):
+ return sum(
+ gaussian(x, params[3 * i], params[3 * i + 1], params[3 * i + 2])
+ for i in range(n)
+ )
+
+ try:
+ popt, _ = curve_fit(
+ _model_fn, self.x_data, self.y_data,
+ p0=p0, bounds=(lo, hi), maxfev=3000 * n,
+ )
+ for i, comp in enumerate(self.components):
+ comp.snap(popt[3 * i], popt[3 * i + 1], popt[3 * i + 2])
+ self.update()
+ except RuntimeError:
+ pass # leave components unchanged if fit did not converge
+
+# ── Assemble ───────────────────────────────────────────────────────────────
+
+components = [
+ GaussianComponent(comp_lines[i], INIT_P[i], COLORS[i])
+ for i in range(2)
+]
+
+model = Model(components, sum_line, x, signal)
+for comp in components:
+ comp.model = model
+
+# ── Key binding — press 'f' to fit ─────────────────────────────────────────
+
+@plot.add_event_handler("key_down")
+def _on_fit(event):
+ if event.key != 'f':
+ return
+ model.fit()
+
+# ── Click handlers — toggle widgets per component ─────────────────────────
+
+for comp, line in zip(components, comp_lines):
+ @line.add_event_handler("pointer_down")
+ def _clicked(event, c=comp):
+ c.toggle()
+
+fig # Interactive
\ No newline at end of file
diff --git a/Examples/Interactive/plot_ipf_explorer.py b/Examples/Interactive/plot_ipf_explorer.py
new file mode 100644
index 00000000..b3aed178
--- /dev/null
+++ b/Examples/Interactive/plot_ipf_explorer.py
@@ -0,0 +1,125 @@
+"""
+Inverse Pole Figure (IPF) Explorer
+==================================
+
+An EBSD-style orientation explorer for a synthetic polycrystal:
+
+* **Left panel** — IPF-Z orientation map, colored with the standard cubic
+ IPF key (red = ⟨001⟩, green = ⟨011⟩, blue = ⟨111⟩). Rendered as a
+ true-color RGB image.
+* **Right panel** — the *reduced 3-D inverse pole figure*: every grain's
+ sample-Z direction, expressed in crystal coordinates and folded into the
+ cubic fundamental sector, plotted as an IPF-colored point cloud on a
+ shaded, wireframed unit sphere.
+
+Drag the crosshair on the map: the grain's orientation is marked with a
+highlighted dot on the sphere, and the sphere **rotates so that direction
+faces you**. Drag on the sphere to orbit freely; the next crosshair move
+re-aims the camera.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(42)
+
+# ── 1. Synthetic polycrystal: nearest-seed grain map ────────────────────────
+H = W = 192
+N_GRAINS = 60
+
+seeds = rng.uniform(0, [H, W], size=(N_GRAINS, 2))
+yy, xx = np.mgrid[0:H, 0:W]
+d2 = (yy[..., None] - seeds[:, 0]) ** 2 + (xx[..., None] - seeds[:, 1]) ** 2
+grain_id = np.argmin(d2, axis=-1) # (H, W) labels
+
+
+# ── 2. Random orientation per grain (uniform rotations via quaternions) ─────
+def random_rotations(n):
+ """Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method)."""
+ u1, u2, u3 = rng.random((3, n))
+ q = np.stack([
+ np.sqrt(1 - u1) * np.sin(2 * np.pi * u2),
+ np.sqrt(1 - u1) * np.cos(2 * np.pi * u2),
+ np.sqrt(u1) * np.sin(2 * np.pi * u3),
+ np.sqrt(u1) * np.cos(2 * np.pi * u3),
+ ], axis=1) # (n, 4) unit quats
+ x, y, z, w = q.T
+ return np.stack([
+ np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1),
+ np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1),
+ np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1),
+ ], axis=1)
+
+
+rotations = random_rotations(N_GRAINS)
+
+# Sample-Z expressed in each grain's crystal frame: d = Rᵀ · ẑ
+dirs = rotations[:, 2, :] # row 2 of R == Rᵀ·ẑ
+
+# ── 3. Reduce to the cubic fundamental sector and IPF-color ────────────────
+# For cubic symmetry, sorting |components| ascending lands every direction
+# in the standard 001–011–111 stereographic triangle.
+reduced = np.sort(np.abs(dirs), axis=1) # (a ≤ b ≤ c)
+a, b, c = reduced.T
+
+# Classic IPF key: distance to each triangle corner → R, G, B
+rgb = np.stack([c - b, b - a, a], axis=1)
+rgb /= rgb.max(axis=1, keepdims=True) + 1e-12 # vivid normalisation
+grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3)
+
+ipf_map = grain_rgb_u8[grain_id] # (H, W, 3) true-color
+
+
+# ── 4. Figure: RGB map + reduced 3-D IPF point cloud ───────────────────────
+fig, (ax_map, ax_ipf) = apl.subplots(
+ 1, 2, figsize=(880, 420),
+ help="Drag the crosshair: the sphere rotates to face that grain's\n"
+ "crystal direction. Drag the sphere to orbit freely.")
+
+vmap = ax_map.imshow(ipf_map) # (H, W, 3) → RGB
+vmap.set_title("IPF-Z orientation map")
+cross = vmap.add_widget("crosshair", cx=W // 2, cy=H // 2, color="#ffffff")
+
+# reduced directions live on the unit sphere → fix bounds to keep the
+# origin centred and the geometry origin-true
+vipf = ax_ipf.scatter3d(
+ reduced[:, 0], reduced[:, 1], reduced[:, 2],
+ colors=grain_rgb_u8, point_size=6,
+ x_label="[100]", y_label="[010]", z_label="[001]",
+ bounds=((-1, 1),) * 3, zoom=1.4,
+)
+vipf.set_title("Reduced 3D IPF (cubic fundamental sector)")
+# Shaded unit sphere with lat/long wireframe behind the direction vectors
+vipf.set_sphere(1.0)
+
+
+# ── 5. Crosshair → highlight + rotate-to-face ───────────────────────────────
+def face_camera(v):
+ """(azimuth°, elevation°) that aim the camera straight down *v*.
+
+ With the turntable camera, the view faces unit vector ``v`` when
+ ``el = asin(vz)`` and ``az = atan2(vx, -vy)``.
+ """
+ vx, vy, vz = v
+ el = np.degrees(np.arcsin(np.clip(vz, -1.0, 1.0)))
+ az = np.degrees(np.arctan2(vx, -vy))
+ return az, el
+
+
+def show_orientation(gid: int) -> None:
+ v = reduced[gid]
+ vipf.set_highlight(*v, color="#ffffff", size=8)
+ az, el = face_camera(v)
+ vipf.set_view(azimuth=az, elevation=el)
+
+
+@cross.add_event_handler("pointer_move")
+def on_move(event):
+ ix = int(np.clip(round(cross.cx), 0, W - 1))
+ iy = int(np.clip(round(cross.cy), 0, H - 1))
+ show_orientation(int(grain_id[iy, ix]))
+
+
+show_orientation(int(grain_id[H // 2, W // 2]))
+
+fig # Interactive
diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py
new file mode 100644
index 00000000..6e19b19f
--- /dev/null
+++ b/Examples/Interactive/plot_key_bindings.py
@@ -0,0 +1,128 @@
+"""
+Key-Press Widget Placement
+==========================
+
+Demonstrates the ``key_down`` event handler API: press a key while the plot
+is focused to add an overlay widget centred on the current cursor position,
+or press **Backspace / Delete** to remove the last widget you clicked.
+
+**Key bindings**
+
++-------------------------------+---------------------------+
+| Key | Action |
++===============================+===========================+
+| ``q`` | Add a rectangle |
++-------------------------------+---------------------------+
+| ``w`` | Add a circle |
++-------------------------------+---------------------------+
+| ``e`` | Add an annulus |
++-------------------------------+---------------------------+
+| ``Backspace`` (macOS ⌫) | Remove last-clicked |
+| ``Delete`` (Windows / Linux) | |
++-------------------------------+---------------------------+
+
+**Built-in 2-D shortcuts** (not overridden in this example):
+
++-------+---------------------------+
+| Key | Action |
++=======+===========================+
+| ``r`` | Reset zoom / pan |
++-------+---------------------------+
+| ``c`` | Toggle colorbar |
++-------+---------------------------+
+| ``l`` | Toggle log scale |
++-------+---------------------------+
+| ``s`` | Toggle symlog scale |
++-------+---------------------------+
+
+The cursor coordinates are available as ``event.xdata`` and ``event.ydata``
+in image-pixel space (column, row), so widgets are centred exactly where
+the cursor was when the key was pressed.
+
+.. note::
+ Move the mouse over the image first so the plot panel receives focus,
+ then press a key. On macOS the backspace key (⌫) is used for deletion;
+ on Windows / Linux use the **Delete** key.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+# ── Synthetic test image ──────────────────────────────────────────────────────
+rng = np.random.default_rng(0)
+N = 256
+x = np.linspace(0, 4 * np.pi, N)
+XX, YY = np.meshgrid(x, x)
+data = np.sin(XX) * np.cos(YY) + 0.15 * rng.standard_normal((N, N))
+
+# ── Figure ────────────────────────────────────────────────────────────────────
+fig, ax = apl.subplots(figsize=(520, 520))
+plot = ax.imshow(data)
+
+# ── Key handlers ─────────────────────────────────────────────────────────────
+
+@plot.add_event_handler("key_down")
+def add_rectangle(event):
+ """Press 'q' — add a rectangle centred on the cursor."""
+ if event.key != 'q':
+ return
+ cx, cy = event.xdata, event.ydata
+ half_w, half_h = N * 0.08, N * 0.08
+ plot.add_widget(
+ "rectangle",
+ x=cx - half_w, y=cy - half_h,
+ w=half_w * 2, h=half_h * 2,
+ color="#ffd54f",
+ )
+
+
+@plot.add_event_handler("key_down")
+def add_circle(event):
+ """Press 'w' — add a circle centred on the cursor."""
+ if event.key != 'w':
+ return
+ plot.add_widget(
+ "circle",
+ cx=event.xdata, cy=event.ydata,
+ r=N * 0.07,
+ color="#80cbc4",
+ )
+
+
+@plot.add_event_handler("key_down")
+def add_annulus(event):
+ """Press 'e' — add an annulus centred on the cursor."""
+ if event.key != 'e':
+ return
+ plot.add_widget(
+ "annular",
+ cx=event.xdata, cy=event.ydata,
+ r_outer=N * 0.12,
+ r_inner=N * 0.06,
+ color="#ce93d8",
+ )
+
+
+# macOS sends 'Backspace' for the ⌫ key; Windows/Linux send 'Delete'.
+# Register both so the example works cross-platform.
+@plot.add_event_handler("key_down")
+def delete_last(event):
+ """Press Backspace/Delete — remove the last widget that was clicked."""
+ if event.key not in ('Backspace', 'Delete'):
+ return
+ wid = event.last_widget_id
+ if wid and wid in {w.id for w in plot.list_widgets()}:
+ plot.remove_widget(wid)
+
+
+# ── Catch-all handler (optional) — log every registered key press ─────────────
+
+@plot.add_event_handler("key_down")
+def log_key(event):
+ xdata = event.xdata
+ ydata = event.ydata
+ pos = f"({xdata:.1f}, {ydata:.1f})" if xdata is not None else "n/a"
+ print(f"[key_down] key={event.key!r} img={pos}"
+ f" last_widget={event.last_widget_id!r}")
+
+fig # Interactive
diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py
new file mode 100644
index 00000000..72a1ee33
--- /dev/null
+++ b/Examples/Interactive/plot_particle_picker.py
@@ -0,0 +1,209 @@
+"""
+HAADF STEM nanoparticle picker.
+=================================
+
+Synthetic HAADF-STEM image with 18 Gaussian nanoparticles on a Poisson
+noise background. Candidate peaks are detected automatically using a
+7×7 local-maximum filter and marked with small grey circles.
+
+**Interaction**
+
+* **Dwell 300 ms** over a candidate — shows the sub-pixel centroid,
+ peak intensity, and estimated FWHM in a floating label.
+* **Double-click** — confirms the pick (green ring).
+* **Shift+double-click** — marks the pick as uncertain (orange ring).
+* **Delete / Backspace** — removes the confirmed pick nearest the
+ cursor.
+* **c** — clears all picks.
+"""
+import numpy as np
+import anyplotlib as apl
+
+
+# ── synthetic data ─────────────────────────────────────────────────────────────
+
+def _make_stem_image(rng: np.random.Generator) -> np.ndarray:
+ img = rng.poisson(lam=5, size=(512, 512)).astype(np.float32)
+ for _ in range(18):
+ cx, cy = rng.integers(30, 482, size=2)
+ sigma = rng.uniform(4, 9)
+ peak = rng.uniform(80, 200)
+ r = int(np.ceil(3 * sigma))
+ y0, y1 = max(0, cy - r), min(512, cy + r + 1)
+ x0, x1 = max(0, cx - r), min(512, cx + r + 1)
+ ys = np.arange(y0, y1)[:, None]
+ xs = np.arange(x0, x1)[None, :]
+ img[y0:y1, x0:x1] += peak * np.exp(
+ -((xs - cx) ** 2 + (ys - cy) ** 2) / (2 * sigma ** 2)
+ )
+ return np.clip(img, 0, 255).astype(np.float32)
+
+
+def _find_candidates(img: np.ndarray) -> list[tuple[int, int]]:
+ """Local maxima via 7x7 sliding-window max filter (pure NumPy)."""
+ from numpy.lib.stride_tricks import sliding_window_view
+ pad = 3
+ padded = np.pad(img, pad, mode="edge")
+ windows = sliding_window_view(padded, (7, 7))
+ local_max = windows.max(axis=(-2, -1))
+ mask = (img == local_max) & (img > 20)
+ ys, xs = np.where(mask)
+ return list(zip(xs.tolist(), ys.tolist()))
+
+
+def _parabolic_centroid(img: np.ndarray, r: int, c: int) -> tuple[float, float]:
+ def _delta(left, center, right):
+ denom = 2 * (2 * center - left - right)
+ return 0.0 if abs(denom) < 1e-6 else (right - left) / denom
+
+ dc = _delta(float(img[r, c - 1]), float(img[r, c]), float(img[r, c + 1]))
+ dr = _delta(float(img[r - 1, c]), float(img[r, c]), float(img[r + 1, c]))
+ return c + dc, r + dr
+
+
+def _gaussian_fwhm(profile: np.ndarray) -> float:
+ p = np.clip(profile.astype(float), 1e-6, None)
+ peak_idx = int(np.argmax(p))
+ if peak_idx == 0 or peak_idx >= len(p) - 1:
+ return 2.0
+ try:
+ a, b, c_ = np.log(p[peak_idx - 1]), np.log(p[peak_idx]), np.log(p[peak_idx + 1])
+ sigma = np.sqrt(-1.0 / (2 * (a + c_ - 2 * b)))
+ except Exception:
+ return 2.0
+ return 2.355 * abs(sigma)
+
+
+def _safe_remove(plot, marker_type: str, name: str) -> None:
+ try:
+ plot.remove_marker(marker_type, name)
+ except KeyError:
+ pass
+
+
+# ── build data ─────────────────────────────────────────────────────────────────
+
+rng = np.random.default_rng(42)
+image = _make_stem_image(rng)
+candidates = _find_candidates(image)
+
+# ── figure ─────────────────────────────────────────────────────────────────────
+
+fig, ax = apl.subplots(1, 1, figsize=(640, 640))
+plot = ax.imshow(image, cmap="gray")
+
+if candidates:
+ cand_arr = np.array(candidates, dtype=float)
+ plot.add_circles(cand_arr, name="candidates", radius=6,
+ facecolors="none", edgecolors="#555555")
+
+info_label = plot.add_widget("label", x=10, y=10, text="", color="#00e5ff", fontsize=11)
+
+picks: list[dict] = []
+
+
+# ── helpers ────────────────────────────────────────────────────────────────────
+
+def _redraw_picks() -> None:
+ _safe_remove(plot, "circles", "picks_certain")
+ _safe_remove(plot, "circles", "picks_uncertain")
+ certain = [p for p in picks if not p["uncertain"]]
+ uncertain = [p for p in picks if p["uncertain"]]
+ if certain:
+ arr = np.array([[p["cx"], p["cy"]] for p in certain])
+ plot.add_circles(arr, name="picks_certain", radius=10,
+ facecolors="none", edgecolors="#00ff88")
+ if uncertain:
+ arr = np.array([[p["cx"], p["cy"]] for p in uncertain])
+ plot.add_circles(arr, name="picks_uncertain", radius=10,
+ facecolors="none", edgecolors="#ff9100")
+
+
+def _nearest_candidate(x: float, y: float, max_dist: float = 12.0):
+ best, best_d = None, max_dist
+ for cx, cy in candidates:
+ d = float(np.hypot(cx - x, cy - y))
+ if d < best_d:
+ best, best_d = (cx, cy), d
+ return best
+
+
+def _nearest_pick_idx(x: float, y: float) -> int | None:
+ if not picks:
+ return None
+ dists = [float(np.hypot(p["cx"] - x, p["cy"] - y)) for p in picks]
+ return int(np.argmin(dists))
+
+
+def _inspect(cx_f: float, cy_f: float) -> tuple[float, float, float, float]:
+ """Return (sub_cx, sub_cy, intensity, fwhm) for the pixel at (cx_f, cy_f)."""
+ r = int(np.clip(round(cy_f), 4, 507))
+ c = int(np.clip(round(cx_f), 4, 507))
+ sub_cx, sub_cy = _parabolic_centroid(image, r, c)
+ intensity = float(image[r, c])
+ row_profile = image[r, max(0, c - 4):min(512, c + 5)]
+ col_profile = image[max(0, r - 4):min(512, r + 5), c]
+ fwhm = (_gaussian_fwhm(row_profile) + _gaussian_fwhm(col_profile)) / 2
+ return sub_cx, sub_cy, intensity, fwhm
+
+
+# ── event handlers ─────────────────────────────────────────────────────────────
+
+def _on_settled(event) -> None:
+ if event.xdata is None or event.ydata is None:
+ return
+ hit = _nearest_candidate(event.xdata, event.ydata)
+ if hit is None:
+ info_label.set(text="")
+ return
+ hx, hy = hit
+ sub_cx, sub_cy, intensity, fwhm = _inspect(hx, hy)
+ info_label.set(
+ text=f"centroid ({sub_cx:.1f}, {sub_cy:.1f})\npeak {intensity:.0f}\nFWHM {fwhm:.2f} px",
+ x=hx + 12,
+ y=hy - 30,
+ )
+
+
+def _on_double_click(event) -> None:
+ if event.xdata is None or event.ydata is None:
+ return
+ hit = _nearest_candidate(event.xdata, event.ydata)
+ if hit is None:
+ return
+ sub_cx, sub_cy, intensity, fwhm = _inspect(*hit)
+ uncertain = "shift" in event.modifiers
+ picks.append({"cx": sub_cx, "cy": sub_cy, "intensity": intensity,
+ "fwhm": fwhm, "uncertain": uncertain})
+ _redraw_picks()
+ tag = "uncertain" if uncertain else "certain"
+ print(f"Pick #{len(picks)} [{tag}]: ({sub_cx:.1f}, {sub_cy:.1f}) "
+ f"peak={intensity:.0f} FWHM={fwhm:.2f} px")
+
+
+def _on_key(event) -> None:
+ if event.key in ("Delete", "Backspace"):
+ x = event.xdata if event.xdata is not None else 256.0
+ y = event.ydata if event.ydata is not None else 256.0
+ idx = _nearest_pick_idx(x, y)
+ if idx is not None:
+ picks.pop(idx)
+ _redraw_picks()
+ elif event.key == "c":
+ picks.clear()
+ _redraw_picks()
+
+
+plot.add_event_handler(_on_settled, "pointer_settled", ms=300, delta=6)
+plot.add_event_handler(_on_double_click, "double_click")
+plot.add_event_handler(_on_key, "key_down")
+
+fig.set_help(
+ "Dwell 300 ms: inspect peak\n"
+ "Double-click: confirm pick (green)\n"
+ "Shift+double-click: uncertain pick (orange)\n"
+ "Delete / Backspace: remove nearest pick\n"
+ "c: clear all picks"
+)
+
+fig # interactive
diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py
new file mode 100644
index 00000000..1f1654c4
--- /dev/null
+++ b/Examples/Interactive/plot_point_widget.py
@@ -0,0 +1,109 @@
+"""
+Draggable Point Widget
+======================
+
+Demonstrates the :class:`~anyplotlib.widgets.PointWidget` on a 1-D panel.
+
+A smooth curve ``f(x) = sin(x) · e^(−x/6)`` is shown together with a
+cyan control point that the user can drag freely inside the plot area.
+
+**Interaction**
+
+* **Drag the point** anywhere inside the plot — the widget reports its
+ data-space ``(x, y)`` position on every frame via the
+ ``pointer_move`` event handler.
+* **Release** — the ``pointer_up`` event handler snaps the point's
+ y-coordinate to the curve value at the dragged x and draws the
+ **tangent line** through that point.
+
+**What is computed on release**
+
+Given the dragged x position *xq*, the code evaluates:
+
+* **Curve value**: ``yq = f(xq)``
+* **Derivative** (central finite difference): ``dy/dx ≈ [f(xq+h) − f(xq−h)] / 2h``
+* **Tangent line**: ``y_tan(x) = yq + slope · (x − xq)``
+
+The tangent line is added with :meth:`~anyplotlib.plot1d.Plot1D.add_line`
+and the previous one is removed, so only one tangent is shown at a time.
+
+.. note::
+ Move the point to an interesting part of the curve (e.g. a local maximum)
+ and release — the tangent will be horizontal there.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+# ── Curve ──────────────────────────────────────────────────────────────────
+x = np.linspace(0.0, 4.0 * np.pi, 512)
+
+def f(t):
+ return np.sin(t) * np.exp(-t / 6.0)
+
+def df(t, h=1e-5):
+ """Central finite-difference derivative of f."""
+ return (f(t + h) - f(t - h)) / (2.0 * h)
+
+y = f(x)
+
+# ── Figure ─────────────────────────────────────────────────────────────────
+fig, ax = apl.subplots(figsize=(680, 340))
+plot = ax.plot(y, axes=[x], units="rad",
+ color="#4fc3f7", linewidth=2.0, label="f(x)")
+
+# ── Initial point widget — placed at the first local maximum ───────────────
+x0_init = float(x[np.argmax(y)])
+y0_init = float(np.max(y))
+pt = plot.add_point_widget(x0_init, y0_init, color="#00e5ff")
+
+# Track the current tangent line handle so we can replace it
+_tangent_line: "apl.Line1D | None" = None # type: ignore[name-defined]
+
+def _draw_tangent(xq: float) -> None:
+ """Snap point to curve, compute slope, draw tangent overlay."""
+ global _tangent_line
+
+ # Evaluate curve and slope at xq
+ yq = float(f(xq))
+ slope = float(df(xq))
+
+ # Snap the widget y to the curve (visual feedback)
+ pt._data["y"] = yq
+ pt._push_fn()
+
+ # Tangent line spans the full visible x range
+ x_tan = np.array([float(x[0]), float(x[-1])])
+ y_tan = yq + slope * (x_tan - xq)
+
+ # Replace previous tangent
+ if _tangent_line is not None:
+ _tangent_line.remove()
+ _tangent_line = plot.add_line(
+ y_tan, x_axis=x_tan,
+ color="#ff7043", linewidth=1.5,
+ linestyle="dashed",
+ label=f"slope = {slope:+.3f}",
+ )
+
+# Draw the tangent at the initial position
+_draw_tangent(x0_init)
+
+
+# ── Callbacks ──────────────────────────────────────────────────────────────
+
+@pt.add_event_handler("pointer_move")
+def _live(event):
+ """Every drag frame — print the current widget position."""
+ print(f" dragging x={event.source.x:.4f} y={event.source.y:.4f}", end="\r")
+
+
+@pt.add_event_handler("pointer_up")
+def _settled(event):
+ """On mouse-up — snap y to the curve and refresh the tangent line."""
+ print(f" released x={event.source.x:.4f} ")
+ _draw_tangent(event.source.x)
+
+
+fig # Interactive
+
diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py
new file mode 100644
index 00000000..0edab46a
--- /dev/null
+++ b/Examples/Interactive/plot_segment_by_contrast.py
@@ -0,0 +1,245 @@
+"""
+Interactive Contrast Segmentation
+===================================
+
+Click on any region of the image to flood-fill all pixels of similar
+intensity — the union of all seeded regions is shown as a live
+semi-transparent overlay on the original image.
+
+**Interaction**
+
++-----------------------------------+-----------------------------------------+
+| Action | Effect |
++===================================+=========================================+
+| **Left-click** | Add a *positive* seed (green dot). |
+| | Flood-fill grows from that pixel. |
++-----------------------------------+-----------------------------------------+
+| **Shift + left-click** | Add a *negative* seed (red dot). |
+| | Subtracts that connected region from |
+| | the current mask. |
++-----------------------------------+-----------------------------------------+
+| **Hover + Delete / Backspace** | Remove the nearest seed within |
+| | 12 image-px of the cursor. |
++-----------------------------------+-----------------------------------------+
+| **+** / **=** | Increase tolerance (grow regions). |
++-----------------------------------+-----------------------------------------+
+| **-** | Decrease tolerance (shrink regions). |
++-----------------------------------+-----------------------------------------+
+| **c** (while focused) | Clear all seeds and reset mask. |
++-----------------------------------+-----------------------------------------+
+
+The current boolean mask numpy array is always accessible as ``mask``.
+The cursor position is exposed as ``event.xdata`` (column) and
+``event.ydata`` (row) in image-pixel coordinates.
+
+.. note::
+ Move the cursor over the plot so it receives keyboard focus before
+ pressing keys. The tolerance is shown in the plot title.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+# ── Synthetic multi-region image ──────────────────────────────────────────────
+# Five Gaussian blobs at different intensity levels on a smooth background,
+# plus mild Poisson-like noise — gives interesting connected regions to segment.
+
+N = 256
+rng = np.random.default_rng(7)
+
+xx, yy = np.meshgrid(np.arange(N), np.arange(N))
+
+def _gauss(cx, cy, sigma, amplitude):
+ return amplitude * np.exp(-((xx - cx)**2 + (yy - cy)**2) / (2 * sigma**2))
+
+image = (
+ _gauss( 64, 72, 28, 0.85) # bright top-left blob
+ + _gauss(190, 60, 22, 0.70) # mid top-right blob
+ + _gauss(128, 128, 40, 0.55) # dim centre blob (large)
+ + _gauss( 55, 195, 20, 0.90) # bright bottom-left blob
+ + _gauss(200, 185, 30, 0.60) # mid bottom-right blob
+ + 0.08 * rng.standard_normal((N, N)) # noise
+)
+# Normalise to [0, 1]
+image = (image - image.min()) / (image.max() - image.min())
+
+# ── Segmentation: pure-numpy BFS flood-fill ───────────────────────────────────
+
+def _bfs_region(img, row: int, col: int, tol: float) -> np.ndarray:
+ """Return a boolean mask for the connected region reachable from (row, col).
+
+ Connectivity is 4-connected. A neighbour is accepted when
+ ``|img[neighbour] - centre_value| <= tol``, where *centre_value* is the
+ intensity of the seed pixel (fixed, not growing).
+ """
+ H, W = img.shape
+ seed_val = img[row, col]
+ visited = np.zeros((H, W), dtype=bool)
+ visited[row, col] = True
+ stack = [(row, col)]
+ while stack:
+ r, c = stack.pop()
+ for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
+ nr, nc = r + dr, c + dc
+ if 0 <= nr < H and 0 <= nc < W and not visited[nr, nc]:
+ if abs(float(img[nr, nc]) - float(seed_val)) <= tol:
+ visited[nr, nc] = True
+ stack.append((nr, nc))
+ return visited
+
+
+def _compute_mask(img, pos_seeds, neg_seeds, tol):
+ """Union of positive-seed BFS regions minus any negative-seed regions."""
+ if not pos_seeds:
+ return np.zeros(img.shape, dtype=bool)
+ combined = np.zeros(img.shape, dtype=bool)
+ for r, c in pos_seeds:
+ combined |= _bfs_region(img, r, c, tol)
+ for r, c in neg_seeds:
+ combined &= ~_bfs_region(img, r, c, tol)
+ return combined
+
+
+# ── State ─────────────────────────────────────────────────────────────────────
+
+pos_seeds: list[tuple[int, int]] = [] # (row, col)
+neg_seeds: list[tuple[int, int]] = [] # (row, col)
+tolerance: float = 0.08
+mask = np.zeros((N, N), dtype=bool) # exposed numpy array
+
+TOL_STEP = 0.01
+TOL_MIN = 0.005
+TOL_MAX = 0.40
+SEED_RADIUS_PIXELS = 5 # marker radius for seed dots
+
+# ── Figure ────────────────────────────────────────────────────────────────────
+
+fig, ax = apl.subplots(figsize=(520, 520),
+ help="Left-click → add positive seed (grow mask)\n"
+ "Shift + Left-click → add negative seed (shrink mask)\n"
+ "Hover + Delete → remove nearest seed\n"
+ "+ / - → increase / decrease tolerance\n"
+ "c → clear all seeds")
+
+plot = ax.imshow(image)
+plot.set_colormap("gray")
+
+# ── Persistent marker groups ──────────────────────────────────────────────────
+# Create named groups once so _refresh() can update them with .set() instead of
+# clear_markers() + add_circles(). Placing the placeholder far off-screen means
+# empty groups render nothing without needing a special empty-list code path.
+_HIDDEN = [[-9999.0, -9999.0]] # off-screen placeholder for an empty group
+
+plot.add_circles(_HIDDEN, name="pos",
+ facecolors="#00c853", edgecolors="#ffffff",
+ radius=SEED_RADIUS_PIXELS)
+plot.add_circles(_HIDDEN, name="neg",
+ facecolors="#b71c1c", edgecolors="#ffffff",
+ radius=SEED_RADIUS_PIXELS)
+
+# ── Helpers: marker refresh and mask push ────────────────────────────────────
+
+def _refresh():
+ """Recompute mask and push updated markers + overlay in one go.
+
+ Updates the two persistent marker groups in-place (no clear → blank → add
+ cycle) so there is no visible flicker when a seed is removed.
+ Each group has its own fixed colour string so the JS fill_color field
+ always receives a valid CSS colour (not a mixed list).
+ """
+ global mask
+ mask = _compute_mask(image, pos_seeds, neg_seeds, tolerance)
+
+ # Update offsets for each group; fall back to off-screen placeholder when empty.
+ pos_offsets = [(c, r) for r, c in pos_seeds] or _HIDDEN
+ neg_offsets = [(c, r) for r, c in neg_seeds] or _HIDDEN
+ plot.markers["circles"]["pos"].set(offsets=pos_offsets)
+ plot.markers["circles"]["neg"].set(offsets=neg_offsets)
+
+ # Transparent overlay — teal for positive mask regions.
+ plot.set_overlay_mask(mask, color="#00e5ff", alpha=0.38)
+
+
+# ── Click handler ─────────────────────────────────────────────────────────────
+
+@plot.add_event_handler("pointer_down")
+def _on_click(event):
+ """Left-click → positive seed; Shift+Left-click → negative seed."""
+ # xdata = column, ydata = row (image-pixel coordinates)
+ col = int(round(float(event.xdata)))
+ row = int(round(float(event.ydata)))
+ # Clamp to image bounds
+ col = max(0, min(N - 1, col))
+ row = max(0, min(N - 1, row))
+
+ if getattr(event, "shift_key", False):
+ neg_seeds.append((row, col))
+ else:
+ pos_seeds.append((row, col))
+
+ _refresh()
+
+
+# ── Key bindings ──────────────────────────────────────────────────────────────
+
+@plot.add_event_handler("key_down")
+def _tol_up(event):
+ """Increase tolerance → flood-fill grows to wider intensity range."""
+ if event.key not in ('+', '='): # '+' on most keyboards requires Shift; '=' is the unshifted key
+ return
+ global tolerance
+ tolerance = min(TOL_MAX, round(tolerance + TOL_STEP, 4))
+ _refresh()
+ print(f" tolerance = {tolerance:.3f}", end="\r")
+
+
+@plot.add_event_handler("key_down")
+def _tol_down(event):
+ """Decrease tolerance → flood-fill shrinks to narrower range."""
+ if event.key != '-':
+ return
+ global tolerance
+ tolerance = max(TOL_MIN, round(tolerance - TOL_STEP, 4))
+ _refresh()
+ print(f" tolerance = {tolerance:.3f}", end="\r")
+
+
+@plot.add_event_handler("key_down")
+def _clear(event):
+ """Clear all seeds and reset the mask."""
+ if event.key != 'c':
+ return
+ pos_seeds.clear()
+ neg_seeds.clear()
+ _refresh()
+ print(" seeds cleared", end="\r")
+
+
+@plot.add_event_handler("key_down")
+def _delete_nearest(event):
+ """Remove the seed (positive or negative) nearest to the cursor."""
+ if event.key not in ('Delete', 'Backspace'):
+ return
+ cx = float(event.xdata)
+ cy = float(event.ydata) # ydata = row
+
+ best_dist = float("inf")
+ best_list = None
+ best_idx = -1
+
+ for lst in (pos_seeds, neg_seeds):
+ for i, (r, c) in enumerate(lst):
+ d = (c - cx) ** 2 + (r - cy) ** 2
+ if d < best_dist:
+ best_dist = d
+ best_list = lst
+ best_idx = i
+
+ if best_list is not None and best_dist <= (12 ** 2):
+ best_list.pop(best_idx)
+ _refresh()
+
+
+fig # Interactive
+
+
diff --git a/Examples/Interactive/plot_segment_by_contrast_advanced.py b/Examples/Interactive/plot_segment_by_contrast_advanced.py
new file mode 100644
index 00000000..e54e57a3
--- /dev/null
+++ b/Examples/Interactive/plot_segment_by_contrast_advanced.py
@@ -0,0 +1,355 @@
+"""
+Advanced Interactive Contrast Segmentation (3 × 3 Grid)
+=========================================================
+
+A 3 × 3 grid of synthetic images, each independently segmented by
+flood-fill. Pass 8 or 9 images as ``images_flat``; the grid always
+has 3 columns and enough rows to fit them all (last cell left blank
+when 8 images are supplied).
+
+**Interaction**
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 70
+
+ * - Action
+ - Effect
+ * - **Left-click**
+ - Add a *positive* seed (green dot) on the clicked panel.
+ * - **Shift + Left-click**
+ - Add a *negative* seed (red dot) — subtracts that connected region
+ from the mask.
+ * - **Ctrl + Left-click**
+ - Add a polygon vertex to the *clip polygon* of the active panel.
+ The mask is restricted to pixels inside the polygon once at least
+ 3 vertices exist.
+ * - **Drag polygon vertex**
+ - Reposition any clip-polygon vertex; mask updates on mouse-up.
+ * - **Hover + Delete / Backspace**
+ - Remove the clip vertex or seed nearest to the cursor (≤ 15 px).
+ * - **+** / **=**
+ - Increase tolerance (grow regions).
+ * - **-**
+ - Decrease tolerance (shrink regions).
+ * - **c**
+ - Clear all seeds (keeps clip polygon).
+ * - **p**
+ - Clear the clip polygon.
+
+After interaction, the resulting boolean mask arrays are in ``masks_flat``
+(same order as ``images_flat``).
+
+.. note::
+ Click on a panel first to give it keyboard focus, then use the key
+ bindings.
+"""
+
+import math
+import numpy as np
+import anyplotlib as apl
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+N = 192 # image size (pixels per side) for the synthetic demo images
+NCOLS = 3 # fixed column count
+
+rng = np.random.default_rng(42)
+xx, yy = np.meshgrid(np.arange(N), np.arange(N))
+
+
+def _gauss(cx, cy, sigma, amplitude):
+ return amplitude * np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2))
+
+
+def _make_image(seed):
+ """Synthesise a unique multi-blob test image."""
+ r = np.random.default_rng(seed)
+ blobs = [
+ (r.integers(30, N - 30), r.integers(30, N - 30),
+ r.integers(15, 35), r.uniform(0.5, 1.0))
+ for _ in range(5)
+ ]
+ img = sum(_gauss(cx, cy, sig, amp) for cx, cy, sig, amp in blobs)
+ img += 0.06 * r.standard_normal((N, N))
+ return (img - img.min()) / (img.max() - img.min())
+
+
+# ── Images — swap this list for your own (8 or 9 arrays of shape (H, W)) ─────
+
+images_flat = [_make_image(seed) for seed in range(1, 9)] # 8 images
+# images_flat = [_make_image(seed) for seed in range(1, 10)] # uncomment for 9
+
+# ── Grid geometry derived from the image list ─────────────────────────────────
+
+n_images = len(images_flat)
+if n_images not in (8, 9):
+ raise ValueError(f"images_flat must contain 8 or 9 images, got {n_images}")
+
+NROWS = math.ceil(n_images / NCOLS) # 3 for both 8 and 9
+
+# ── BFS flood-fill ────────────────────────────────────────────────────────────
+
+def _bfs_region(img, row, col, tol):
+ H, W = img.shape
+ seed_val = float(img[row, col])
+ visited = np.zeros((H, W), dtype=bool)
+ visited[row, col] = True
+ stack = [(row, col)]
+ while stack:
+ r, c = stack.pop()
+ for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
+ nr, nc = r + dr, c + dc
+ if 0 <= nr < H and 0 <= nc < W and not visited[nr, nc]:
+ if abs(float(img[nr, nc]) - seed_val) <= tol:
+ visited[nr, nc] = True
+ stack.append((nr, nc))
+ return visited
+
+
+def _compute_mask(img, pos_seeds, neg_seeds, tol, clip_poly):
+ """Flood-fill union, optionally restricted to a drawn polygon."""
+ H, W = img.shape
+ if not pos_seeds:
+ return np.zeros((H, W), dtype=bool)
+ combined = np.zeros((H, W), dtype=bool)
+ for r, c in pos_seeds:
+ combined |= _bfs_region(img, r, c, tol)
+ for r, c in neg_seeds:
+ combined &= ~_bfs_region(img, r, c, tol)
+
+ if clip_poly and len(clip_poly) >= 3:
+ # Pure-numpy even-odd ray-casting point-in-polygon
+ # Polygon vertices are [x, y] = [col, row] in image-pixel space
+ poly = np.asarray(clip_poly, dtype=float) # (K, 2) as [x, y]
+ rows = np.arange(H, dtype=float)
+ cols = np.arange(W, dtype=float)
+ gc, gr = np.meshgrid(cols, rows) # gc[r,c]=col, gr[r,c]=row
+ xs = gc.ravel() # x = col index
+ ys = gr.ravel() # y = row index
+ inside = np.zeros(H * W, dtype=bool)
+ n_v = len(poly)
+ xp, yp = poly[:, 0], poly[:, 1]
+ for i in range(n_v):
+ x1, y1 = xp[i], yp[i]
+ x2, y2 = xp[(i + 1) % n_v], yp[(i + 1) % n_v]
+ cond = ((y1 > ys) != (y2 > ys)) & (
+ xs < (x2 - x1) * (ys - y1) / (y2 - y1 + 1e-12) + x1
+ )
+ inside ^= cond
+ combined &= inside.reshape(H, W)
+
+ return combined
+
+
+# ── Per-panel state (flat) ────────────────────────────────────────────────────
+
+TOL_STEP = 0.01
+TOL_MIN = 0.005
+TOL_MAX = 0.40
+SEED_RADIUS = 4
+_HIDDEN = [[-9999.0, -9999.0]]
+_OFFSCREEN_TRI = [[-9990.0, -9990.0], [-9989.0, -9990.0], [-9989.0, -9989.0]]
+
+_CMAPS = ["gray", "viridis", "plasma", "inferno", "magma",
+ "cividis", "hot", "cool", "bone"]
+
+panel_state = [
+ {"pos_seeds": [], "neg_seeds": [], "tolerance": 0.08, "clip_poly": []}
+ for _ in range(n_images)
+]
+masks_flat = [np.zeros((N, N), dtype=bool) for _ in range(n_images)]
+active_idx = [0]
+
+# ── Figure ────────────────────────────────────────────────────────────────────
+
+fig, axes = apl.subplots(
+ NROWS, NCOLS,
+ figsize=(900, 900),
+ help=(
+ "Left-click → positive seed (grow)\n"
+ "Shift + Left-click → negative seed (shrink)\n"
+ "Ctrl + Left-click → add clip-polygon vertex\n"
+ "Drag polygon vertex → reposition (mask updates on release)\n"
+ "Delete / Backspace → remove nearest vertex or seed\n"
+ "+ / - → tolerance up / down\n"
+ "c → clear seeds\n"
+ "p → clear clip polygon"
+ ),
+)
+
+# Flatten axes to a 1-D list (row-major, matches images_flat)
+axes_flat = [axes[r][c] for r in range(NROWS) for c in range(NCOLS)]
+
+# Build plot objects only for panels that have an image
+plots_flat = []
+clip_wids = [] # one PolygonWidget per panel
+
+for idx in range(n_images):
+ p = axes_flat[idx].imshow(images_flat[idx])
+ p.set_colormap(_CMAPS[idx % len(_CMAPS)])
+
+ # Seed marker groups
+ p.add_circles(_HIDDEN, name="pos",
+ facecolors="#69f0ae", edgecolors="#ffffff",
+ radius=SEED_RADIUS)
+ p.add_circles(_HIDDEN, name="neg",
+ facecolors="#ff5252", edgecolors="#ffffff",
+ radius=SEED_RADIUS)
+
+ # Preview dots for partial polygon (< 3 vertices — before widget takes over)
+ p.add_circles(_HIDDEN, name="clip_pts",
+ facecolors="#ffeb3b", edgecolors="#ffffff",
+ radius=3)
+
+ # Draggable polygon widget — starts offscreen until ≥ 3 vertices are placed.
+ # The widget provides per-vertex handles that can be dragged in the browser.
+ wid = p.add_widget("polygon", color="#ffeb3b", vertices=_OFFSCREEN_TRI)
+ clip_wids.append(wid)
+
+ plots_flat.append(p)
+
+
+# ── Refresh helper ────────────────────────────────────────────────────────────
+
+def _refresh(idx):
+ """Recompute mask and push all markers + overlay for panel ``idx``."""
+ try:
+ st = panel_state[idx]
+ p = plots_flat[idx]
+ img = images_flat[idx]
+
+ masks_flat[idx] = _compute_mask(
+ img, st["pos_seeds"], st["neg_seeds"],
+ st["tolerance"], st["clip_poly"],
+ )
+
+ # Seed marker dots
+ pos_off = [(c, r) for r, c in st["pos_seeds"]] or _HIDDEN
+ neg_off = [(c, r) for r, c in st["neg_seeds"]] or _HIDDEN
+ p.markers["circles"]["pos"].set(offsets=pos_off)
+ p.markers["circles"]["neg"].set(offsets=neg_off)
+
+ # Clip polygon widget — show real vertices once we have ≥ 3, else offscreen
+ clip = st["clip_poly"]
+ if len(clip) >= 3:
+ clip_wids[idx].set(vertices=clip)
+ # Hide the preview dots (widget handles are enough)
+ p.markers["circles"]["clip_pts"].set(offsets=_HIDDEN)
+ else:
+ clip_wids[idx].set(vertices=_OFFSCREEN_TRI)
+ # Show partial-polygon vertex dots during the building phase
+ clip_off = [[v[0], v[1]] for v in clip] or _HIDDEN
+ p.markers["circles"]["clip_pts"].set(offsets=clip_off)
+
+ # Mask overlay
+ p.set_overlay_mask(masks_flat[idx], color="#00e5ff", alpha=0.38)
+
+ except Exception as exc:
+ import traceback
+ print(f"[panel {idx}] _refresh error: {exc}")
+ traceback.print_exc()
+
+
+# ── Click & key handlers (one closure per panel) ──────────────────────────────
+
+def _make_handlers(idx):
+ p = plots_flat[idx]
+ wid = clip_wids[idx]
+ img = images_flat[idx]
+ H, W = img.shape
+
+ # ── Polygon widget: sync vertices → panel_state after any drag ────────────
+ @wid.add_event_handler("pointer_up")
+ def _poly_dragged(event):
+ active_idx[0] = idx
+ vs = wid.vertices # widget data is synced from JS before callbacks
+ if vs is None:
+ return
+ # Filter out any accidental off-screen dummy vertices
+ real = [[float(v[0]), float(v[1])] for v in vs
+ if abs(float(v[0])) < 9000 and abs(float(v[1])) < 9000]
+ panel_state[idx]["clip_poly"] = real
+ _refresh(idx)
+
+ # ── Click: add seed or polygon vertex ─────────────────────────────────────
+ @p.add_event_handler("pointer_down")
+ def _on_click(event):
+ if event.xdata is None or event.ydata is None:
+ return
+ active_idx[0] = idx
+ st = panel_state[idx]
+ r_px = max(0, min(H - 1, int(round(float(event.ydata)))))
+ c_px = max(0, min(W - 1, int(round(float(event.xdata)))))
+ if "ctrl" in event.modifiers:
+ st["clip_poly"].append([float(c_px), float(r_px)])
+ elif "shift" in event.modifiers:
+ st["neg_seeds"].append((r_px, c_px))
+ else:
+ st["pos_seeds"].append((r_px, c_px))
+ _refresh(idx)
+
+ # ── Keys: tolerance, clear, delete-nearest ─────────────────────────────────
+ @p.add_event_handler("key_down")
+ def _on_key(event):
+ active_idx[0] = idx
+ st = panel_state[idx]
+ if event.key in ("+", "="):
+ st["tolerance"] = min(TOL_MAX, round(st["tolerance"] + TOL_STEP, 4))
+ _refresh(idx)
+ elif event.key == "-":
+ st["tolerance"] = max(TOL_MIN, round(st["tolerance"] - TOL_STEP, 4))
+ _refresh(idx)
+ elif event.key == "c":
+ st["pos_seeds"].clear()
+ st["neg_seeds"].clear()
+ _refresh(idx)
+ elif event.key == "p":
+ st["clip_poly"].clear()
+ _refresh(idx)
+ elif event.key in ("Delete", "Backspace"):
+ _delete_nearest(event)
+
+ def _delete_nearest(event):
+ st = panel_state[idx]
+ if event.xdata is None or event.ydata is None:
+ return
+ cx = float(event.xdata)
+ cy = float(event.ydata)
+ HIT2 = 15 ** 2 # hit radius squared (px)
+
+ # Check clip-polygon vertices first (they're on top visually)
+ best_dist = float("inf")
+ best_poly_i = -1
+ for i, (vx, vy) in enumerate(st["clip_poly"]):
+ d = (vx - cx) ** 2 + (vy - cy) ** 2
+ if d < best_dist:
+ best_dist = d
+ best_poly_i = i
+
+ if best_poly_i >= 0 and best_dist <= HIT2:
+ st["clip_poly"].pop(best_poly_i)
+ _refresh(idx)
+ return
+
+ # Otherwise check seeds
+ best_dist = float("inf")
+ best_list = None
+ best_i = -1
+ for lst in (st["pos_seeds"], st["neg_seeds"]):
+ for i, (r, c) in enumerate(lst):
+ d = (c - cx) ** 2 + (r - cy) ** 2
+ if d < best_dist:
+ best_dist = d
+ best_list = lst
+ best_i = i
+
+ if best_list is not None and best_dist <= HIT2:
+ best_list.pop(best_i)
+ _refresh(idx)
+
+
+_handlers = [_make_handlers(idx) for idx in range(n_images)]
+
+fig
+
diff --git a/Examples/Interactive/plot_spectra_roi_inspector.py b/Examples/Interactive/plot_spectra_roi_inspector.py
new file mode 100644
index 00000000..eef36b9c
--- /dev/null
+++ b/Examples/Interactive/plot_spectra_roi_inspector.py
@@ -0,0 +1,265 @@
+"""
+ROI-to-spectrum inspector for a 3-D EDS hyperspectral dataset.
+==============================================================
+
+A synthetic ``(256, 256, 300)`` EDS datacube — one 300-channel
+spectrum per scan position. Four rectangular ROIs overlay the
+total-counts image (HAADF proxy). Entering an ROI **sums all spectra
+within the rectangle** (spatial sum over every scan position in the
+box) and displays the result in the top-right panel. Draggable
+coloured range widgets on the spectrum define the integration window
+for each element; each bar height is the **channel sum of the ROI
+spectrum within that window**.
+
+**Interaction**
+
+* **Move cursor inside an ROI** — spatially sums the spectra of all
+ scan positions inside the box; updates the line plot and bars live.
+* **Drag an ROI rectangle** — repositions the ROI on the image.
+* **Release drag** — recomputes the spatial sum spectrum for the new
+ position.
+* **Drag a coloured range widget** on the spectrum — adjusts the
+ integration window for that element; bar heights update on every
+ drag frame.
+"""
+import numpy as np
+import anyplotlib as apl
+
+
+# ── synthetic 3-D hyperspectral datacube ──────────────────────────────────────
+# Shape: (NY, NX, NC). dataset[y, x, :] is the 300-channel EDS spectrum at
+# scan position (x, y). Each pixel is an independent Poisson draw from the
+# expected spectrum for its phase.
+
+NY, NX, NC = 256, 256, 300
+ENERGY = np.linspace(0.1, 3.0, NC) # keV
+
+EDS_ELEMENTS = ["O", "Fe", "Al", "Si"]
+_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV
+_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)]
+_EDS_SIGMA = 0.025
+_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"]
+
+_PEAKS = np.array([
+ np.exp(-0.5 * ((ENERGY - ev) / _EDS_SIGMA) ** 2)
+ for ev in _EDS_EV
+]) # shape (4, NC)
+
+# Per-phase element weight vectors [O, Fe, Al, Si] and expected total
+# counts per pixel (determines peak-to-background ratio and brightness).
+_PHASE_DEFS = [
+ dict(weights=[0.10, 0.05, 0.65, 0.20], counts=80), # 0 Matrix
+ dict(weights=[0.05, 0.08, 0.12, 0.75], counts=200), # 1 Precipitate A
+ dict(weights=[0.12, 0.60, 0.18, 0.10], counts=150), # 2 Precipitate B
+ dict(weights=[0.62, 0.12, 0.18, 0.08], counts=110), # 3 Grain Boundary
+]
+
+
+def _expected_spectrum(phase_idx: int) -> np.ndarray:
+ p = _PHASE_DEFS[phase_idx]
+ bkg = 3.0 * np.exp(-ENERGY / 0.8)
+ spec = bkg + (_PEAKS * np.array(p["weights"])[:, None]).sum(axis=0) * p["counts"]
+ return np.clip(spec, 0, None).astype(np.float64)
+
+
+def _make_dataset(rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
+ phases = np.zeros((NY, NX), dtype=np.int8) # 0 = Matrix
+
+ # Precipitate A (Si-rich) — cluster in top-left quadrant
+ for cx, cy, r in [(60, 60, 30), (75, 50, 22), (45, 75, 20)]:
+ ys, xs = np.ogrid[:NY, :NX]
+ phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 1
+
+ # Precipitate B (Fe-rich) — cluster in bottom-right quadrant
+ for cx, cy, r in [(195, 195, 27), (180, 210, 20), (210, 180, 17)]:
+ ys, xs = np.ogrid[:NY, :NX]
+ phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 2
+
+ # Grain boundary — thin horizontal band
+ phases[120:135, :] = 3
+
+ dataset = np.empty((NY, NX, NC), dtype=np.float32)
+ flat = dataset.reshape(-1, NC)
+ phases_flat = phases.ravel()
+ for pidx, pdef in enumerate(_PHASE_DEFS):
+ sel = phases_flat == pidx
+ n = int(sel.sum())
+ if n == 0:
+ continue
+ lam = _expected_spectrum(pidx)
+ flat[sel] = rng.poisson(lam, size=(n, NC)).astype(np.float32)
+
+ return dataset, phases
+
+
+rng = np.random.default_rng(99)
+dataset, _phase_map = _make_dataset(rng)
+
+# Total-counts image used as the HAADF-proxy display image
+_display_img = dataset.sum(axis=2)
+
+
+# ── ROI definitions (r0, r1, c0, c1) in scan-pixel coordinates ────────────────
+
+ROIS: dict[str, tuple[int, int, int, int]] = {
+ "Matrix": ( 25, 100, 155, 230),
+ "Precipitate A": ( 25, 100, 25, 100),
+ "Precipitate B": (155, 230, 155, 230),
+ "Grain Boundary": (115, 140, 25, 230),
+}
+_ROI_COLORS: dict[str, str] = {
+ "Matrix": "#4fc3f7",
+ "Precipitate A": "#aed581",
+ "Precipitate B": "#ff8a65",
+ "Grain Boundary": "#ba68c8",
+}
+
+
+def _sum_spectrum(r0: int, r1: int, c0: int, c1: int) -> np.ndarray:
+ """Spatial sum of all spectra within the ROI box."""
+ r0 = max(0, min(NY - 1, r0)); r1 = max(1, min(NY, r1))
+ c0 = max(0, min(NX - 1, c0)); c1 = max(1, min(NX, c1))
+ return dataset[r0:r1, c0:c1, :].sum(axis=(0, 1))
+
+
+def _roi_at(x: float, y: float) -> str | None:
+ for name, (r0, r1, c0, c1) in ROIS.items():
+ if c0 <= x <= c1 and r0 <= y <= r1:
+ return name
+ return None
+
+
+# ── layout ─────────────────────────────────────────────────────────────────────
+
+fig = apl.Figure(figsize=(1100, 560))
+gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1])
+
+ax_img = fig.add_subplot(gs[:, 0]) # total-counts image — left column
+ax_spec = fig.add_subplot(gs[0, 1]) # ROI sum spectrum — top right
+ax_bar = fig.add_subplot(gs[1, 1]) # element bar chart — bottom right
+
+img_plot = ax_img.imshow(_display_img, cmap="gray")
+
+_init_spec = _sum_spectrum(*ROIS["Matrix"]).astype(np.float32)
+spec_plot = ax_spec.plot(_init_spec, axes=[ENERGY],
+ color=_ROI_COLORS["Matrix"], linewidth=1.5,
+ units="keV", y_units="counts")
+bar_plot = ax_bar.bar(EDS_ELEMENTS, [0.0] * 4)
+
+
+# ── ROI rectangle overlays on the image ───────────────────────────────────────
+
+_roi_widgets: dict[str, object] = {}
+for roi_name, (r0, r1, c0, c1) in ROIS.items():
+ w = img_plot.add_widget(
+ "rectangle",
+ x=float(c0), y=float(r0),
+ w=float(c1 - c0), h=float(r1 - r0),
+ color=_ROI_COLORS[roi_name],
+ )
+ _roi_widgets[roi_name] = w
+
+status_label = img_plot.add_widget(
+ "label", x=4, y=248, text="Move cursor into an ROI",
+ color="#ffffff", fontsize=10,
+)
+
+
+# ── adjustable range widgets on the spectrum ───────────────────────────────────
+
+range_widgets: dict[str, object] = {}
+for elem, (lo, hi), color in zip(EDS_ELEMENTS, _EDS_WIN, _EDS_COLORS):
+ range_widgets[elem] = spec_plot.add_range_widget(lo, hi, color=color)
+
+_current_spectrum: list[np.ndarray] = [_init_spec.copy()]
+
+
+def _channel_sum(x0: float, x1: float) -> float:
+ """Sum of ROI spectrum counts within the energy window [x0, x1]."""
+ mask = (ENERGY >= x0) & (ENERGY <= x1)
+ return float(_current_spectrum[0][mask].sum()) if mask.any() else 0.0
+
+
+def _update_bars() -> None:
+ heights = np.array([
+ _channel_sum(range_widgets[e].x0, range_widgets[e].x1)
+ for e in EDS_ELEMENTS
+ ])
+ max_h = heights.max() or 1.0
+ bar_plot.set_data((heights / max_h).tolist())
+
+
+for _rw in range_widgets.values():
+ _rw.add_event_handler(lambda event: _update_bars(), "pointer_move")
+ _rw.add_event_handler(lambda event: _update_bars(), "pointer_up")
+
+_update_bars()
+
+
+# ── update helper ──────────────────────────────────────────────────────────────
+
+_current_roi: list[str | None] = [None]
+_roi_dragging = False
+
+
+def _update_for_roi(roi_name: str) -> None:
+ _current_roi[0] = roi_name
+ r0, r1, c0, c1 = ROIS[roi_name]
+ _current_spectrum[0] = _sum_spectrum(r0, r1, c0, c1).astype(np.float32)
+ spec_plot.set_data(_current_spectrum[0], x_axis=ENERGY)
+ spec_plot.set_color(_ROI_COLORS[roi_name])
+ _update_bars()
+ n_pixels = (r1 - r0) * (c1 - c0)
+ status_label.set(text=f"ROI: {roi_name} ({n_pixels} px)")
+
+
+# ── event handlers ─────────────────────────────────────────────────────────────
+
+def _on_move(event) -> None:
+ if _roi_dragging or event.xdata is None or event.ydata is None:
+ return
+ roi_name = _roi_at(event.xdata, event.ydata)
+ if roi_name is None or roi_name == _current_roi[0]:
+ return
+ _update_for_roi(roi_name)
+
+
+def _on_enter(event) -> None:
+ status_label.set(text="Move cursor into an ROI")
+
+
+def _on_leave(event) -> None:
+ status_label.set(text="Move cursor over image to inspect")
+ _current_roi[0] = None
+
+
+img_plot.add_event_handler(_on_move, "pointer_move")
+img_plot.add_event_handler(_on_enter, "pointer_enter")
+img_plot.add_event_handler(_on_leave, "pointer_leave")
+
+for roi_name, widget in _roi_widgets.items():
+ def _make_drag_handler():
+ def _on_drag(event) -> None:
+ global _roi_dragging
+ _roi_dragging = True
+ return _on_drag
+
+ def _make_release_handler(name, wgt):
+ def _on_release(event) -> None:
+ global _roi_dragging
+ _roi_dragging = False
+ x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h
+ ROIS[name] = (int(y), int(y + h), int(x), int(x + w))
+ _update_for_roi(name)
+ return _on_release
+
+ widget.add_event_handler(_make_drag_handler(), "pointer_move")
+ widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up")
+
+fig.set_help(
+ "Move cursor inside an ROI: spatial sum spectrum + bars\n"
+ "Drag ROI rectangle: repositions ROI; release recomputes\n"
+ "Drag a coloured range widget: adjust element integration window"
+)
+
+fig # Interactive
diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py
new file mode 100644
index 00000000..bb45ff4d
--- /dev/null
+++ b/Examples/Interactive/plot_threshold_explorer.py
@@ -0,0 +1,138 @@
+"""
+Live intensity thresholding on a multi-phase STEM image.
+=========================================================
+
+A side-by-side view: the left panel shows a synthetic 512×512 STEM
+image with a red overlay marking pixels above the threshold; the right
+panel shows a 32-bin intensity histogram with a yellow vertical line at
+the current threshold value.
+
+**Interaction**
+
+* **Shift+Scroll** over the image — adjusts the threshold by ±2 per
+ wheel tick (plain scroll pans/zooms the image as normal).
+* **Click** a histogram bar — jumps the threshold to that bin's upper
+ edge.
+* **Dwell 400 ms** over the image — shows pixel coordinates and
+ intensity in the bottom-left label.
+"""
+import numpy as np
+import anyplotlib as apl
+
+
+# ── synthetic data ─────────────────────────────────────────────────────────────
+
+def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray:
+ img = rng.normal(20, 5, (512, 512)).astype(np.float32)
+
+ # Grain A — 6 large blobs
+ for _ in range(6):
+ cx, cy = rng.integers(60, 452, size=2)
+ r = rng.integers(40, 80)
+ ys, xs = np.ogrid[:512, :512]
+ mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2
+ img[mask] = rng.normal(80, 8, mask.sum())
+
+ # Grain B — 8 smaller blobs
+ for _ in range(8):
+ cx, cy = rng.integers(40, 472, size=2)
+ r = rng.integers(15, 35)
+ ys, xs = np.ogrid[:512, :512]
+ mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2
+ img[mask] = rng.normal(130, 10, mask.sum())
+
+ # Voids — 12 dark circular regions
+ for _ in range(12):
+ cx, cy = rng.integers(20, 492, size=2)
+ r = rng.integers(8, 20)
+ ys, xs = np.ogrid[:512, :512]
+ mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2
+ img[mask] = rng.normal(5, 2, mask.sum())
+
+ return np.clip(img, 0, 255).astype(np.float32)
+
+
+rng = np.random.default_rng(13)
+image = _make_multiphase_image(rng)
+
+NBINS = 32
+counts, bin_edges = np.histogram(image, bins=NBINS, range=(0, 255))
+bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
+x_labels = [f"{int(v)}" for v in bin_centers]
+
+threshold = 100.0
+
+
+# ── figure ─────────────────────────────────────────────────────────────────────
+
+fig, (ax_img, ax_hist) = apl.subplots(1, 2, figsize=(900, 500))
+
+img_plot = ax_img.imshow(image, cmap="gray")
+hist_plot = ax_hist.bar(x_labels, counts.astype(float))
+
+# Track the threshold vline widget so we can remove/replace it
+_thresh_widget = None
+
+
+def _pct_above(thresh: float) -> float:
+ return 100.0 * float((image >= thresh).sum()) / image.size
+
+
+def _update_display(thresh: float) -> None:
+ global threshold, _thresh_widget
+ threshold = float(np.clip(thresh, 0, 255))
+ mask = image >= threshold
+ img_plot.set_overlay_mask(mask, color="#ff0000", alpha=0.35)
+ # Remove old threshold line widget and add a new one
+ if _thresh_widget is not None:
+ try:
+ hist_plot.remove_widget(_thresh_widget)
+ except KeyError:
+ pass
+ _thresh_widget = hist_plot.add_vline_widget(threshold, color="#ffeb3b")
+ pct = _pct_above(threshold)
+ print(f"Threshold: {threshold:.0f} | {pct:.1f}% above")
+
+
+_update_display(threshold)
+
+info_label = img_plot.add_widget("label", x=10, y=490, text="", color="#ffeb3b", fontsize=11)
+
+
+# ── event handlers ─────────────────────────────────────────────────────────────
+
+def _on_wheel(event) -> None:
+ if "shift" not in event.modifiers:
+ return
+ delta = -2.0 * np.sign(event.dy) if event.dy != 0 else 0.0
+ _update_display(threshold + delta)
+
+
+def _on_bar_click(event) -> None:
+ idx = event.bar_index
+ if idx is None:
+ return
+ new_thresh = float(bin_edges[idx + 1])
+ _update_display(new_thresh)
+
+
+def _on_settled(event) -> None:
+ if event.xdata is None or event.ydata is None:
+ return
+ x = int(np.clip(round(event.xdata), 0, 511))
+ y = int(np.clip(round(event.ydata), 0, 511))
+ intensity = float(image[y, x])
+ info_label.set(text=f"px ({x}, {y}): {intensity:.0f}", x=10, y=490)
+
+
+img_plot.add_event_handler(_on_wheel, "wheel")
+img_plot.add_event_handler(_on_settled, "pointer_settled", ms=400, delta=4)
+hist_plot.add_event_handler(_on_bar_click, "pointer_down")
+
+fig.set_help(
+ "Shift+Scroll over image: adjust threshold ±2\n"
+ "Click histogram bar: jump to bin upper edge\n"
+ "Dwell 400 ms over image: inspect pixel intensity"
+)
+
+fig # Interactive
diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py
new file mode 100644
index 00000000..47f73fb9
--- /dev/null
+++ b/Examples/Interactive/plot_voxel_grain_explorer.py
@@ -0,0 +1,292 @@
+"""
+3-D Voxel Grain Explorer
+========================
+
+An orthoslice viewer for a synthetic 3-D polycrystal (voxel grain map),
+in the style of EBSD/tomography volume browsers:
+
+* **Top row** — the three orthogonal slices (XY, XZ, YZ) through the
+ current voxel, rendered as true-colour IPF-RGB images. Each carries a
+ draggable crosshair; the three crosshairs are **linked**: dragging one
+ moves the slice planes of the other two views.
+* **Bottom left** — the grain volume rendered as **translucent shaded
+ voxels** with three draggable **plane widgets** (the slice selectors in
+ 3-D). Voxels lying on a selected plane render more opaque, so the
+ current slices glow inside the volume. Drag a plane along its normal to
+ re-slice — the 2-D views follow.
+* **Bottom right** — the *reduced 3-D inverse pole figure*: the selected
+ voxel's grain orientation is highlighted on the wireframed unit sphere,
+ which **rotates to face that crystal direction**.
+
+Everything is bidirectionally linked: drag a crosshair OR a 3-D plane and
+the other views re-cut, the voxel highlight moves, and the IPF re-aims.
+Drag empty space on either 3-D panel to orbit it freely.
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(11)
+
+# ── 1. Synthetic 3-D polycrystal: nearest-seed voxel grain map ──────────────
+N = 48 # volume is N³ voxels, indexed V[z, y, x]
+N_GRAINS = 40
+
+seeds = rng.uniform(0, N, size=(N_GRAINS, 3)) # (z, y, x)
+zz, yy, xx = np.mgrid[0:N, 0:N, 0:N]
+gid = np.zeros((N, N, N), dtype=np.int32)
+best = np.full((N, N, N), np.inf)
+for g, (sz, sy, sx) in enumerate(seeds):
+ d = (zz - sz) ** 2 + (yy - sy) ** 2 + (xx - sx) ** 2
+ closer = d < best
+ gid[closer] = g
+ best[closer] = d[closer]
+
+
+# ── 2. Orientations, cubic fundamental-sector reduction, IPF colours ────────
+def random_rotations(n):
+ """Uniform random rotation matrices, shape (n, 3, 3) (Shoemake method)."""
+ u1, u2, u3 = rng.random((3, n))
+ q = np.stack([
+ np.sqrt(1 - u1) * np.sin(2 * np.pi * u2),
+ np.sqrt(1 - u1) * np.cos(2 * np.pi * u2),
+ np.sqrt(u1) * np.sin(2 * np.pi * u3),
+ np.sqrt(u1) * np.cos(2 * np.pi * u3),
+ ], axis=1)
+ x, y, z, w = q.T
+ return np.stack([
+ np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], -1),
+ np.stack([2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], -1),
+ np.stack([2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], -1),
+ ], axis=1)
+
+
+rotations = random_rotations(N_GRAINS)
+dirs = rotations[:, 2, :] # Rᵀ·ẑ per grain
+reduced = np.sort(np.abs(dirs), axis=1) # cubic 001–011–111
+a, b, c = reduced.T
+rgb = np.stack([c - b, b - a, a], axis=1)
+rgb /= rgb.max(axis=1, keepdims=True) + 1e-12
+grain_rgb_u8 = (rgb * 255).astype(np.uint8) # (N_GRAINS, 3)
+
+# ── 3. Voxels for the 3-D volume view ───────────────────────────────────────
+# Rather than a sparse random subsample of the whole volume (where the
+# highlight marker floats in empty space because almost no cube sits at the
+# selected voxel), render the voxels that actually lie ON the three slice
+# planes. This anchors the highlight exactly where the slices intersect,
+# shows real slice contents in 3-D, and scales: the on-plane count is
+# ~3·(N/step)² regardless of N, so it stays fast even for a 256³ volume.
+VSTEP = max(1, N // 48) # in-plane downsample → ~48² cubes per plane
+
+# Voxel cube size in data units. A touch larger than VSTEP so the three
+# slabs read as solid sheets rather than a dotted grid.
+VOXSIZE = float(VSTEP) * 1.3
+
+
+def slice_voxels(ix, iy, iz):
+ """Voxel centres + colours lying on the x=ix, y=iy, z=iz planes."""
+ s = VSTEP
+ rng_ax = np.arange(0, N, s)
+ parts = []
+ # z = iz plane (vary x, y)
+ yy2, xx2 = np.meshgrid(rng_ax, rng_ax, indexing="ij")
+ parts.append(np.column_stack([xx2.ravel(), yy2.ravel(),
+ np.full(xx2.size, iz)]))
+ # y = iy plane (vary x, z)
+ zz2, xx2 = np.meshgrid(rng_ax, rng_ax, indexing="ij")
+ parts.append(np.column_stack([xx2.ravel(), np.full(xx2.size, iy),
+ zz2.ravel()]))
+ # x = ix plane (vary y, z)
+ zz2, yy2 = np.meshgrid(rng_ax, rng_ax, indexing="ij")
+ parts.append(np.column_stack([np.full(yy2.size, ix), yy2.ravel(),
+ zz2.ravel()]))
+ pts = np.vstack(parts) # (M, 3) as (x,y,z)
+ cols = grain_rgb_u8[gid[pts[:, 2], pts[:, 1], pts[:, 0]]] # gid[z,y,x]
+ return pts, cols
+
+# ── 4. Figure: 3 slices on top, volume + IPF below ──────────────────────────
+gs = apl.GridSpec(2, 3)
+fig = apl.Figure(figsize=(960, 640),
+ help="Drag a crosshair: the other two slices re-cut, the\n"
+ "3-D voxel highlight moves, and the IPF sphere rotates\n"
+ "to the selected grain's crystal direction.\n"
+ "Drag the 3-D panels to orbit them freely.")
+
+ax_xy = fig.add_subplot(gs[0, 0])
+ax_xz = fig.add_subplot(gs[0, 1])
+ax_yz = fig.add_subplot(gs[0, 2])
+ax_vol = fig.add_subplot(gs[1, 0])
+ax_ipf = fig.add_subplot(gs[1, 1:3])
+
+ix, iy, iz = N // 2, N // 2, N // 2 # integer slice indices
+fx, fy, fz = float(ix), float(iy), float(iz) # smooth highlight pos
+
+px = [np.arange(N)] * 2 # pixel axes → gutters
+
+v_xy = ax_xy.imshow(grain_rgb_u8[gid[iz]], axes=px, units="vox")
+v_xz = ax_xz.imshow(grain_rgb_u8[gid[:, iy, :]], axes=px, units="vox")
+v_yz = ax_yz.imshow(grain_rgb_u8[gid[:, :, ix]], axes=px, units="vox")
+v_xy.set_xlabel("x"); v_xy.set_ylabel("y")
+v_xz.set_xlabel("x"); v_xz.set_ylabel("z")
+v_yz.set_xlabel("y"); v_yz.set_ylabel("z")
+
+cw_xy = v_xy.add_widget("crosshair", cx=ix, cy=iy, color="#ffffff")
+cw_xz = v_xz.add_widget("crosshair", cx=ix, cy=iz, color="#ffffff")
+cw_yz = v_yz.add_widget("crosshair", cx=iy, cy=iz, color="#ffffff")
+
+_vpts, _vcols = slice_voxels(ix, iy, iz)
+v_vol = ax_vol.voxels(
+ _vpts[:, 0], _vpts[:, 1], _vpts[:, 2], colors=_vcols,
+ size=VOXSIZE, alpha=0.55,
+ x_label="x", y_label="y", z_label="z",
+ bounds=((0, N - 1),) * 3, zoom=1.1,
+)
+v_vol.set_title("Grain volume — drag a plane to re-slice")
+
+# Three draggable slice-selector planes; on-plane voxels render opaque
+pw_yz = v_vol.add_widget("plane", axis="x", position=ix, color="#ff5252", alpha=0.18)
+pw_xz = v_vol.add_widget("plane", axis="y", position=iy, color="#69f0ae", alpha=0.18)
+pw_xy = v_vol.add_widget("plane", axis="z", position=iz, color="#40c4ff", alpha=0.18)
+
+v_ipf = ax_ipf.scatter3d(
+ reduced[:, 0], reduced[:, 1], reduced[:, 2],
+ colors=grain_rgb_u8, point_size=6,
+ x_label="[100]", y_label="[010]", z_label="[001]",
+ bounds=((-1, 1),) * 3, zoom=1.4,
+)
+v_ipf.set_title("Reduced 3D IPF")
+v_ipf.set_sphere(1.0)
+
+
+# ── 5. Linked updates ────────────────────────────────────────────────────────
+def face_camera(v):
+ """Turntable (az°, el°) aiming the camera straight down unit vector v."""
+ el = np.degrees(np.arcsin(np.clip(v[2], -1.0, 1.0)))
+ az = np.degrees(np.arctan2(v[0], -v[1]))
+ return az, el
+
+
+_busy = [False] # programmatic widget.set() fires callbacks — guard re-entry
+
+
+def update(source: str) -> None:
+ """Re-cut the other slices, move crosshairs/highlights, re-aim the IPF."""
+ _busy[0] = True
+ try:
+ # Coalesce every panel mutation below into one push per panel — without
+ # this, a single crosshair drag fires ~8 full-state pushes across the
+ # comm boundary, which is the main source of Pyodide lag.
+ with fig.batch():
+ if source != "xy":
+ v_xy.set_data(grain_rgb_u8[gid[iz]])
+ cw_xy.set(cx=ix, cy=iy)
+ if source != "xz":
+ v_xz.set_data(grain_rgb_u8[gid[:, iy, :]])
+ cw_xz.set(cx=ix, cy=iz)
+ if source != "yz":
+ v_yz.set_data(grain_rgb_u8[gid[:, :, ix]])
+ cw_yz.set(cx=iy, cy=iz)
+ v_xy.set_title(f"XY slice — z={iz}")
+ v_xz.set_title(f"XZ slice — y={iy}")
+ v_yz.set_title(f"YZ slice — x={ix}")
+
+ # 3-D slice-selector planes follow at the SMOOTH position (skipped for
+ # the one being dragged, so its own live position isn't overwritten).
+ if source != "px":
+ pw_yz.set(position=fx)
+ if source != "py":
+ pw_xz.set(position=fy)
+ if source != "pz":
+ pw_xy.set(position=fz)
+
+ # Re-cut the 3-D slab voxels to the new slice indices so the volume
+ # view shows the actual slice contents (bounded ~3·(N/VSTEP)² voxels).
+ _p, _c = slice_voxels(ix, iy, iz)
+ v_vol.set_data(_p[:, 0], _p[:, 1], _p[:, 2])
+ v_vol.set_point_colors(_c)
+
+ # Highlight tracks the SMOOTH plane positions (fx,fy,fz) so the marker
+ # glides with the planes instead of jumping by whole voxels.
+ v_vol.set_highlight(fx, fy, fz, color="#ffffff", size=7)
+
+ g = int(gid[iz, iy, ix])
+ v_ipf.set_highlight(*reduced[g], color="#ffffff", size=8)
+ az, el = face_camera(reduced[g])
+ v_ipf.set_view(azimuth=az, elevation=el)
+ finally:
+ _busy[0] = False
+
+
+def _clipf(v):
+ """Clamp a float position to the volume range (kept smooth for the marker)."""
+ return float(np.clip(v, 0.0, N - 1))
+
+
+def _i(v):
+ """Round a float position to the nearest integer slice index."""
+ return int(round(v))
+
+
+@cw_xy.add_event_handler("pointer_move")
+def _moved_xy(event):
+ global ix, iy, fx, fy
+ if _busy[0]:
+ return
+ fx, fy = _clipf(cw_xy.cx), _clipf(cw_xy.cy)
+ ix, iy = _i(fx), _i(fy)
+ update("xy")
+
+
+@cw_xz.add_event_handler("pointer_move")
+def _moved_xz(event):
+ global ix, iz, fx, fz
+ if _busy[0]:
+ return
+ fx, fz = _clipf(cw_xz.cx), _clipf(cw_xz.cy)
+ ix, iz = _i(fx), _i(fz)
+ update("xz")
+
+
+@cw_yz.add_event_handler("pointer_move")
+def _moved_yz(event):
+ global iy, iz, fy, fz
+ if _busy[0]:
+ return
+ fy, fz = _clipf(cw_yz.cx), _clipf(cw_yz.cy)
+ iy, iz = _i(fy), _i(fz)
+ update("yz")
+
+
+@pw_yz.add_event_handler("pointer_move")
+def _plane_x(event):
+ global ix, fx
+ if _busy[0]:
+ return
+ fx = _clipf(pw_yz.position)
+ ix = _i(fx)
+ update("px")
+
+
+@pw_xz.add_event_handler("pointer_move")
+def _plane_y(event):
+ global iy, fy
+ if _busy[0]:
+ return
+ fy = _clipf(pw_xz.position)
+ iy = _i(fy)
+ update("py")
+
+
+@pw_xy.add_event_handler("pointer_move")
+def _plane_z(event):
+ global iz, fz
+ if _busy[0]:
+ return
+ fz = _clipf(pw_xy.position)
+ iz = _i(fz)
+ update("pz")
+
+
+update("none")
+
+fig # Interactive
diff --git a/Examples/Markers/plot_arrows.py b/Examples/Markers/plot_arrows.py
index cb4c7f91..7c6eae59 100644
--- a/Examples/Markers/plot_arrows.py
+++ b/Examples/Markers/plot_arrows.py
@@ -3,27 +3,27 @@
======
Draw vector arrows on a 2-D image with
-:meth:`~anyplotlib.figure_plots.Plot2D.add_arrows`.
+:meth:`~anyplotlib.plot2d.Plot2D.add_arrows`.
Use ``markers["arrows"]["name"].set(...)`` to update them live.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(3)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
tails = rng.uniform(15, 100, (8, 2))
U = rng.uniform(-18, 18, 8)
V = rng.uniform(-18, 18, 8)
-
v.add_arrows(tails, U, V, name="flow",
edgecolors="#76ff03", linewidths=2.0,
label="flow vectors")
+
fig
# %%
diff --git a/Examples/Markers/plot_circles.py b/Examples/Markers/plot_circles.py
new file mode 100644
index 00000000..c10fb944
--- /dev/null
+++ b/Examples/Markers/plot_circles.py
@@ -0,0 +1,34 @@
+"""
+Circles
+=======
+
+Mark circular features on a 2-D image with
+:meth:`~anyplotlib.plot2d.Plot2D.add_circles`.
+Use ``markers["circles"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(0)
+data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
+data = (data - data.min()) / (data.max() - data.min())
+xy = np.linspace(0, 10, 128)
+
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
+v = ax.imshow(data, axes=[xy, xy], units="nm")
+
+centres = rng.uniform(15, 113, (8, 2))
+v.add_circles(centres, name="spots", radius=10,
+ edgecolors="#ff1744", facecolors="#ff174433",
+ labels=[f"#{i}" for i in range(8)])
+
+fig
+
+# %%
+# Live update
+# -----------
+# Call ``.set()`` on the marker group to push any change immediately.
+
+v.markers["circles"]["spots"].set(radius=16, edgecolors="#ffcc00",
+ facecolors="#ffcc0033")
+fig
diff --git a/Examples/Markers/plot_ellipses.py b/Examples/Markers/plot_ellipses.py
index 6f2be5de..c641c095 100644
--- a/Examples/Markers/plot_ellipses.py
+++ b/Examples/Markers/plot_ellipses.py
@@ -3,18 +3,18 @@
========
Draw ellipses on a 2-D image with
-:meth:`~anyplotlib.figure_plots.Plot2D.add_ellipses`.
+:meth:`~anyplotlib.plot2d.Plot2D.add_ellipses`.
Use ``markers["ellipses"]["name"].set(...)`` to update them live.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(2)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
centres = np.array([[32.0, 32.0], [64.0, 96.0], [96.0, 48.0]])
@@ -23,6 +23,7 @@
name="grains",
edgecolors="#ff9100", facecolors="#ff910033",
label="grains", labels=["A", "B", "C"])
+
fig
# %%
@@ -34,4 +35,3 @@
edgecolors="#69f0ae",
facecolors="#69f0ae33")
fig
-
diff --git a/Examples/Markers/plot_horizontal_lines.py b/Examples/Markers/plot_horizontal_lines.py
new file mode 100644
index 00000000..579e07d8
--- /dev/null
+++ b/Examples/Markers/plot_horizontal_lines.py
@@ -0,0 +1,29 @@
+"""
+Horizontal Lines
+================
+
+Draw static horizontal threshold lines on a 1-D plot with
+:meth:`~anyplotlib.plot1d.Plot1D.add_hlines`.
+Use ``markers["hlines"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+x = np.linspace(0, 4 * np.pi, 512)
+signal = np.sin(x)
+
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
+v = ax.plot(signal, axes=[x], units="rad")
+
+v.add_hlines([0.5, 0.0, -0.5], name="thresholds",
+ color="#69f0ae", linewidths=1.5,
+ label="thresholds", labels=["+0.5", "zero", "-0.5"])
+
+fig
+
+# %%
+# Live update
+# -----------
+
+v.markers["hlines"]["thresholds"].set(color="#ff1744", linewidths=2.0)
+fig
diff --git a/Examples/Markers/plot_line_segments.py b/Examples/Markers/plot_line_segments.py
new file mode 100644
index 00000000..2f901493
--- /dev/null
+++ b/Examples/Markers/plot_line_segments.py
@@ -0,0 +1,40 @@
+"""
+Line Segments
+=============
+
+Draw line segments on a 2-D image with
+:meth:`~anyplotlib.plot2d.Plot2D.add_lines`.
+Use ``markers["lines"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(4)
+data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
+data = (data - data.min()) / (data.max() - data.min())
+xy = np.linspace(0, 10, 128)
+
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
+v = ax.imshow(data, axes=[xy, xy], units="nm")
+
+segments = np.array([
+ [[ 10.0, 10.0], [118.0, 10.0]],
+ [[118.0, 10.0], [118.0, 118.0]],
+ [[118.0, 118.0], [ 10.0, 118.0]],
+ [[ 10.0, 118.0], [ 10.0, 10.0]],
+ [[ 10.0, 10.0], [118.0, 118.0]],
+])
+v.add_lines(segments, name="frame",
+ edgecolors="#00e5ff", linewidths=1.5,
+ label="frame",
+ labels=["top", "right", "bottom", "left", "diagonal"])
+
+fig
+
+# %%
+# Live update
+# -----------
+# Update stroke colour and width for all segments at once.
+
+v.markers["lines"]["frame"].set(edgecolors="#ff9100", linewidths=2.5)
+fig
diff --git a/Examples/Markers/plot_points.py b/Examples/Markers/plot_points.py
new file mode 100644
index 00000000..aa713b89
--- /dev/null
+++ b/Examples/Markers/plot_points.py
@@ -0,0 +1,32 @@
+"""
+Points
+======
+
+Mark specific (x, y) positions on a 1-D plot with
+:meth:`~anyplotlib.plot1d.Plot1D.add_points`.
+Use ``markers["points"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+x = np.linspace(0, 4 * np.pi, 512)
+signal = np.sin(x)
+
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
+v = ax.plot(signal, axes=[x], units="rad")
+
+peak_x = np.array([np.pi / 2, 5 * np.pi / 2, 9 * np.pi / 2])
+offsets = np.column_stack([peak_x, np.sin(peak_x)])
+v.add_points(offsets, name="peaks",
+ sizes=8, color="#ff1744", facecolors="#ff174433",
+ label="peaks", labels=["P1", "P2", "P3"])
+
+fig
+
+# %%
+# Live update
+# -----------
+
+v.markers["points"]["peaks"].set(sizes=12, color="#ffcc00",
+ facecolors="#ffcc0033")
+fig
diff --git a/Examples/Markers/plot_polygons.py b/Examples/Markers/plot_polygons.py
new file mode 100644
index 00000000..16af4076
--- /dev/null
+++ b/Examples/Markers/plot_polygons.py
@@ -0,0 +1,38 @@
+"""
+Polygons
+========
+
+Draw closed polygons on a 2-D image with
+:meth:`~anyplotlib.plot2d.Plot2D.add_polygons`.
+Use ``markers["polygons"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(5)
+data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
+data = (data - data.min()) / (data.max() - data.min())
+xy = np.linspace(0, 10, 128)
+
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
+v = ax.imshow(data, axes=[xy, xy], units="nm")
+
+triangle = [[64.0, 10.0], [100.0, 60.0], [28.0, 60.0]]
+hexagon = [[64.0 + 28 * np.cos(np.radians(60 * k)),
+ 95.0 + 28 * np.sin(np.radians(60 * k))]
+ for k in range(6)]
+v.add_polygons([triangle, hexagon], name="shapes",
+ edgecolors="#69f0ae", facecolors="#69f0ae22",
+ linewidths=2.0,
+ label="shapes", labels=["triangle", "hexagon"])
+
+fig
+
+# %%
+# Live update
+# -----------
+# Change the stroke and fill colour of every polygon at once.
+
+v.markers["polygons"]["shapes"].set(edgecolors="#e040fb",
+ facecolors="#e040fb33")
+fig
diff --git a/Examples/Markers/plot_rectangles.py b/Examples/Markers/plot_rectangles.py
new file mode 100644
index 00000000..32a7b279
--- /dev/null
+++ b/Examples/Markers/plot_rectangles.py
@@ -0,0 +1,34 @@
+"""
+Rectangles
+==========
+
+Draw bounding boxes on a 2-D image with
+:meth:`~anyplotlib.plot2d.Plot2D.add_rectangles`.
+Use ``markers["rectangles"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(1)
+data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
+data = (data - data.min()) / (data.max() - data.min())
+xy = np.linspace(0, 10, 128)
+
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
+v = ax.imshow(data, axes=[xy, xy], units="nm")
+
+centres = rng.uniform(20, 108, (5, 2))
+v.add_rectangles(centres, widths=22, heights=14, name="boxes",
+ edgecolors="#00e5ff", facecolors="#00e5ff22",
+ labels=[f"R{i}" for i in range(5)])
+
+fig
+
+# %%
+# Live update
+# -----------
+
+v.markers["rectangles"]["boxes"].set(widths=30, heights=20,
+ edgecolors="#ff9100",
+ facecolors="#ff910033")
+fig
diff --git a/Examples/Markers/plot_squares.py b/Examples/Markers/plot_squares.py
index 125dfe23..8b170967 100644
--- a/Examples/Markers/plot_squares.py
+++ b/Examples/Markers/plot_squares.py
@@ -3,18 +3,18 @@
=======
Draw squares on a 2-D image with
-:meth:`~anyplotlib.figure_plots.Plot2D.add_squares`.
+:meth:`~anyplotlib.plot2d.Plot2D.add_squares`.
Use ``markers["squares"]["name"].set(...)`` to update them live.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(6)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
centres = np.array([[32.0, 32.0], [64.0, 64.0], [96.0, 96.0],
@@ -24,6 +24,7 @@
name="tiles",
edgecolors="#00e5ff", facecolors="#00e5ff22",
label="tiles", labels=[f"T{i}" for i in range(5)])
+
fig
# %%
diff --git a/Examples/Markers/plot_texts.py b/Examples/Markers/plot_texts.py
index d0898ffe..8ea2891d 100644
--- a/Examples/Markers/plot_texts.py
+++ b/Examples/Markers/plot_texts.py
@@ -3,18 +3,18 @@
===========
Place text annotations on a 2-D image with
-:meth:`~anyplotlib.figure_plots.Plot2D.add_texts`.
+:meth:`~anyplotlib.plot2d.Plot2D.add_texts`.
Use ``markers["texts"]["name"].set(...)`` to update them live.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(7)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_texts([[4.0, 4.0], [4.0, 116.0], [88.0, 4.0], [88.0, 116.0]],
@@ -22,6 +22,7 @@
name="corners",
color="#ffeb3b", fontsize=12,
label="corners")
+
fig
# %%
@@ -31,4 +32,3 @@
v.markers["texts"]["corners"].set(color="#e040fb", fontsize=14)
fig
-
diff --git a/Examples/Markers/plot_vertical_lines.py b/Examples/Markers/plot_vertical_lines.py
new file mode 100644
index 00000000..d3d53c8b
--- /dev/null
+++ b/Examples/Markers/plot_vertical_lines.py
@@ -0,0 +1,29 @@
+"""
+Vertical Lines
+==============
+
+Draw static vertical marker lines on a 1-D plot with
+:meth:`~anyplotlib.plot1d.Plot1D.add_vlines`.
+Use ``markers["vlines"]["name"].set(...)`` to update them live.
+"""
+import numpy as np
+import anyplotlib as apl
+
+x = np.linspace(0, 4 * np.pi, 512)
+signal = np.sin(x)
+
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
+v = ax.plot(signal, axes=[x], units="rad")
+
+v.add_vlines([np.pi, 2 * np.pi, 3 * np.pi], name="pi_mult",
+ color="#00e5ff", linewidths=1.5,
+ label="pi multiples", labels=["\u03c0", "2\u03c0", "3\u03c0"])
+
+fig
+
+# %%
+# Live update
+# -----------
+
+v.markers["vlines"]["pi_mult"].set(color="#ff9100", linewidths=2.0)
+fig
diff --git a/Examples/PlotTypes/README.rst b/Examples/PlotTypes/README.rst
new file mode 100644
index 00000000..8c02b0e9
--- /dev/null
+++ b/Examples/PlotTypes/README.rst
@@ -0,0 +1,3 @@
+Plot Types
+----------
+A collection of short examples showing different plot types.
\ No newline at end of file
diff --git a/Examples/PlotTypes/plot_3d.py b/Examples/PlotTypes/plot_3d.py
new file mode 100644
index 00000000..7829d613
--- /dev/null
+++ b/Examples/PlotTypes/plot_3d.py
@@ -0,0 +1,74 @@
+"""
+3D Plotting
+===========
+
+Demonstrate the three 3-D geometry types supported by
+:meth:`~anyplotlib.Axes.plot_surface`,
+:meth:`~anyplotlib.Axes.scatter3d`, and
+:meth:`~anyplotlib.Axes.plot3d`.
+Drag to rotate, scroll to zoom, press **R** to reset the view.
+"""
+import numpy as np
+import anyplotlib as apl
+
+# ── Surface ───────────────────────────────────────────────────────────────────
+x = np.linspace(-3, 3, 60)
+y = np.linspace(-3, 3, 60)
+XX, YY = np.meshgrid(x, y)
+ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2))
+
+fig, ax = apl.subplots(1, 1, figsize=(520, 480))
+surf = ax.plot_surface(XX, YY, ZZ,
+ colormap="viridis",
+ x_label="x", y_label="y", z_label="sin(r)")
+
+fig
+
+# %%
+# Scatter plot
+# ------------
+
+rng = np.random.default_rng(1)
+n = 300
+theta = rng.uniform(0, 2 * np.pi, n)
+phi = rng.uniform(0, np.pi, n)
+r = rng.uniform(0.6, 1.0, n)
+xs = r * np.sin(phi) * np.cos(theta)
+ys = r * np.sin(phi) * np.sin(theta)
+zs = r * np.cos(phi)
+
+fig2, ax2 = apl.subplots(1, 1, figsize=(480, 480))
+sc = ax2.scatter3d(xs, ys, zs,
+ color="#4fc3f7", point_size=3,
+ x_label="x", y_label="y", z_label="z")
+
+fig2
+
+# %%
+# 3-D line — parametric helix
+# ----------------------------
+
+t = np.linspace(0, 4 * np.pi, 300)
+hx = np.cos(t)
+hy = np.sin(t)
+hz = t / (4 * np.pi)
+
+fig3, ax3 = apl.subplots(1, 1, figsize=(480, 480))
+ln = ax3.plot3d(hx, hy, hz,
+ color="#ff7043", linewidth=2,
+ x_label="cos t", y_label="sin t", z_label="t")
+
+fig3
+
+# %%
+# Update the surface data live
+# ----------------------------
+# Call :meth:`~anyplotlib.Plot3D.set_data` to replace the geometry
+# without recreating the panel.
+
+ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2))
+surf.set_data(XX, YY, ZZ2)
+surf.set_colormap("plasma")
+surf.set_view(azimuth=30, elevation=40)
+
+fig
diff --git a/Examples/PlotTypes/plot_bar.py b/Examples/PlotTypes/plot_bar.py
new file mode 100644
index 00000000..dfb966ee
--- /dev/null
+++ b/Examples/PlotTypes/plot_bar.py
@@ -0,0 +1,151 @@
+"""
+Bar Chart
+=========
+
+Demonstrate :meth:`~anyplotlib.Axes.bar` with:
+
+* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)``
+* Vertical and horizontal orientations, per-bar colours, category labels
+* **Grouped bars** — pass a 2-D *height* array ``(N, G)``
+* **Log-scale value axis** — ``log_scale=True``
+* Live data updates via :meth:`~anyplotlib.PlotBar.set_data`
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(7)
+
+# ── 1. Vertical bar chart — monthly sales ────────────────────────────────────
+# The first positional argument is now *x* (positions or labels), matching
+# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``.
+months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78],
+ dtype=float)
+
+fig1, ax1 = apl.subplots(1, 1, figsize=(640, 340))
+bar1 = ax1.bar(
+ months, # x — category strings become x_labels automatically
+ sales, # height
+ width=0.6,
+ color="#4fc3f7",
+ show_values=True,
+ units="Month",
+ y_units="Units sold",
+)
+fig1
+
+# %%
+# Horizontal bar chart — ranked items
+# -------------------------------------
+# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours
+# to ``colors`` to give each bar its own colour.
+
+categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn",
+ "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"]
+scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float)
+
+palette = [
+ "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5",
+ "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726",
+]
+
+fig2, ax2 = apl.subplots(1, 1, figsize=(540, 400))
+bar2 = ax2.bar(
+ categories,
+ scores,
+ orient="h",
+ colors=palette,
+ width=0.65,
+ show_values=True,
+ y_units="Popularity score",
+)
+fig2
+
+# %%
+# Grouped bar chart — quarterly comparison
+# -----------------------------------------
+# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by
+# side for each category. Provide ``group_labels`` to show a legend and
+# ``group_colors`` to customise each group's colour.
+
+quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
+q_data = np.array([
+ [42, 58, 51], # Jan — Q1, Q2, Q3
+ [55, 61, 59], # Feb
+ [48, 70, 65], # Mar
+ [63, 75, 71], # Apr
+ [71, 69, 80], # May
+ [68, 83, 77], # Jun
+], dtype=float) # shape (6, 3) → 6 categories, 3 groups
+
+fig3, ax3 = apl.subplots(1, 1, figsize=(680, 340))
+bar3 = ax3.bar(
+ quarters,
+ q_data,
+ width=0.8,
+ group_labels=["Q1", "Q2", "Q3"],
+ group_colors=["#4fc3f7", "#ff7043", "#66bb6a"],
+ show_values=False,
+ y_units="Sales",
+)
+fig3
+
+# %%
+# Log-scale value axis
+# ---------------------
+# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values
+# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at
+# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5×
+# multiples.
+
+log_labels = ["A", "B", "C", "D", "E"]
+log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float)
+
+fig4, ax4 = apl.subplots(1, 1, figsize=(500, 340))
+bar4 = ax4.bar(
+ log_labels,
+ log_vals,
+ log_scale=True,
+ color="#ab47bc",
+ show_values=True,
+ y_units="Count (log scale)",
+)
+fig4
+
+# %%
+# Side-by-side comparison — update data live
+# -------------------------------------------
+# Place two :class:`~anyplotlib.PlotBar` panels in one figure.
+# Call :meth:`~anyplotlib.PlotBar.set_data` to swap in Q2 data —
+# the value-axis range recalculates automatically.
+
+q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float)
+q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float)
+all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+
+fig5, (ax_left, ax_right) = apl.subplots(1, 2, figsize=(820, 320))
+bar_left = ax_left.bar(
+ all_months, q1, width=0.6,
+ color="#4fc3f7", show_values=False, y_units="Q1 sales",
+)
+bar_right = ax_right.bar(
+ all_months, q1, width=0.6,
+ color="#ff7043", show_values=False, y_units="Q2 sales",
+)
+bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically
+
+fig5
+
+# %%
+# Mutate colours, annotations, and scale at runtime
+# --------------------------------------------------
+# :meth:`~anyplotlib.PlotBar.set_color` repaints all bars,
+# :meth:`~anyplotlib.PlotBar.set_show_values` toggles labels,
+# :meth:`~anyplotlib.PlotBar.set_log_scale` switches the
+# value-axis between linear and logarithmic.
+
+bar1.set_color("#ff7043")
+bar1.set_show_values(False)
+fig1
diff --git a/Examples/PlotTypes/plot_gridspec_custom.py b/Examples/PlotTypes/plot_gridspec_custom.py
new file mode 100644
index 00000000..31e3dd4a
--- /dev/null
+++ b/Examples/PlotTypes/plot_gridspec_custom.py
@@ -0,0 +1,187 @@
+"""
+Custom Grid Layouts with GridSpec
+==================================
+
+:class:`~anyplotlib.GridSpec` lets you build multi-panel figures where panels
+have different sizes and span multiple grid cells. This gallery shows the most
+common patterns.
+
+All examples use the **bare** ``Figure + GridSpec`` workflow — the figure's
+grid dimensions are inferred automatically from the GridSpec the first time
+``add_subplot`` is called.
+
+Overview
+--------
+
+1. **Side-by-side spectra** — two equal 1-D panels in one row (``1×2`` grid).
+2. **Image + spectra** — image spanning full width, two spectra below
+ (``2×2`` grid with ``height_ratios=[3, 1]``).
+3. **Image + histogram** — classic EM layout: large image on top, thin
+ histogram strip below (``2×1`` grid with ``height_ratios=[3, 1]``).
+4. **Three-column** — three equal columns in a single row (``1×3`` grid).
+5. **Asymmetric widths** — wide overview left, narrow detail right
+ (``1×2`` grid with ``width_ratios=[2, 1]``).
+6. **Complex** — spanning top panel plus two bottom panels (``2×2`` grid).
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(42)
+t = np.linspace(0.0, 2.0 * np.pi, 512)
+
+# ── 1. Side-by-side spectra (1×2, equal widths) ───────────────────────────────
+# %%
+# Side-by-side spectra
+# --------------------
+# The simplest multi-panel case: two 1-D spectra in one row. Each panel
+# receives exactly half the figure width with a full-height inner plot area.
+# Both panels share the same height so their axes baselines align visually.
+
+gs1 = apl.GridSpec(1, 2)
+fig1 = apl.Figure(figsize=(720, 280))
+
+sp_left = fig1.add_subplot(gs1[0, 0]).plot(
+ np.sin(t) + rng.normal(scale=0.05, size=len(t)),
+ color="#4fc3f7", label="channel A")
+
+sp_right = fig1.add_subplot(gs1[0, 1]).plot(
+ np.cos(t) + rng.normal(scale=0.05, size=len(t)),
+ color="#ff7043", label="channel B")
+
+fig1 # Interactive
+
+# ── 2. Image + two spectra (2×2, height_ratios=[3, 1]) ────────────────────────
+# %%
+# Image on top, two spectra below
+# --------------------------------
+# A ``2×2`` grid with ``height_ratios=[3, 1]`` puts a wide image in the upper
+# three-quarters and two comparison spectra side-by-side in the lower quarter.
+#
+# The spanning subplot ``gs2[0, :]`` covers all columns in row 0, so the image
+# gets the full figure width.
+
+N = 128
+x = np.linspace(-4, 4, N)
+y = np.linspace(-4, 4, N)
+XX, YY = np.meshgrid(x, y)
+image = np.exp(-(XX**2 + YY**2) / 4) + 0.3 * np.exp(-((XX - 2)**2 + YY**2) / 1)
+image += rng.normal(scale=0.03, size=image.shape)
+
+gs2 = apl.GridSpec(2, 2, height_ratios=[3, 1])
+fig2 = apl.Figure(figsize=(640, 560))
+
+fig2.add_subplot(gs2[0, :]).imshow(image.astype(np.float32), cmap="inferno")
+
+row_profile = image[N // 2, :]
+col_profile = image[:, N // 2]
+
+fig2.add_subplot(gs2[1, 0]).plot(
+ row_profile, axes=[x], units="nm",
+ color="#4fc3f7", label="row profile")
+
+fig2.add_subplot(gs2[1, 1]).plot(
+ col_profile, axes=[y], units="nm",
+ color="#ff7043", label="col profile")
+
+fig2 # Interactive
+
+# ── 3. Image + histogram (2×1, height_ratios=[3, 1]) ──────────────────────────
+# %%
+# Image + histogram strip
+# -----------------------
+# A ``2×1`` grid with ``height_ratios=[3, 1]`` is the classic layout for
+# showing an image with its intensity histogram below. The image occupies
+# three-quarters of the height; the histogram strip the remaining quarter.
+
+gs3 = apl.GridSpec(2, 1, height_ratios=[3, 1])
+fig3 = apl.Figure(figsize=(500, 600))
+
+fig3.add_subplot(gs3[0, 0]).imshow(image.astype(np.float32), cmap="viridis")
+
+counts, edges = np.histogram(image.ravel(), bins=64)
+bin_centers = 0.5 * (edges[:-1] + edges[1:])
+fig3.add_subplot(gs3[1, 0]).plot(
+ counts.astype(float), axes=[bin_centers],
+ color="#aed581", label="histogram")
+
+fig3 # Interactive
+
+# ── 4. Three equal columns (1×3) ──────────────────────────────────────────────
+# %%
+# Three-column layout
+# -------------------
+# A ``1×3`` grid gives three equal panels that are easy to compare visually.
+# Useful for showing the same quantity at three different conditions or times.
+
+gs4 = apl.GridSpec(1, 3)
+fig4 = apl.Figure(figsize=(900, 240))
+
+spectra = [
+ np.sin(t * (i + 1)) + rng.normal(scale=0.08, size=len(t))
+ for i in range(3)
+]
+colors = ["#4fc3f7", "#ff7043", "#aed581"]
+labels = ["f₁", "f₂", "f₃"]
+
+for i, (data, color, label) in enumerate(zip(spectra, colors, labels)):
+ fig4.add_subplot(gs4[0, i]).plot(data, color=color, label=label)
+
+fig4 # Interactive
+
+# ── 5. Asymmetric widths (1×2, width_ratios=[2, 1]) ──────────────────────────
+# %%
+# Asymmetric column widths
+# ------------------------
+# ``width_ratios=[2, 1]`` makes the left panel twice as wide as the right.
+# A common use-case is a broad overview spectrum on the left and a zoomed
+# detail region on the right.
+
+energy = np.linspace(280, 295, 1024)
+peak = np.exp(-0.5 * ((energy - 284.8) / 0.3)**2)
+peak2 = 0.35 * np.exp(-0.5 * ((energy - 286.2) / 0.3)**2)
+spectrum = peak + peak2 + 0.1 * np.exp(-0.05 * (energy - 280)) \
+ + rng.normal(scale=0.01, size=len(energy))
+
+gs5 = apl.GridSpec(1, 2, width_ratios=[2, 1])
+fig5 = apl.Figure(figsize=(720, 260))
+
+fig5.add_subplot(gs5[0, 0]).plot(
+ spectrum, axes=[energy], units="eV",
+ color="#4fc3f7", label="survey")
+
+mask = (energy >= 283.5) & (energy <= 286.5)
+fig5.add_subplot(gs5[0, 1]).plot(
+ spectrum[mask], axes=[energy[mask]], units="eV",
+ color="#ff7043", label="detail")
+
+fig5 # Interactive
+
+# ── 6. Complex layout: spanning top + two bottom (2×2, height_ratios=[2, 1]) ──
+# %%
+# Complex layout: spanning top panel
+# -----------------------------------
+# A ``2×2`` grid where ``gs6[0, :]`` spans both columns creates a wide panel
+# on top (e.g. a summed spectrum) with two comparison panels below it.
+# ``height_ratios=[2, 1]`` gives the top panel twice the height of each bottom
+# panel.
+
+summed = spectrum + rng.normal(scale=0.02, size=len(energy))
+diff1 = rng.normal(scale=0.05, size=len(energy))
+diff2 = rng.normal(scale=0.05, size=len(energy))
+
+gs6 = apl.GridSpec(2, 2, height_ratios=[2, 1])
+fig6 = apl.Figure(figsize=(720, 480))
+
+fig6.add_subplot(gs6[0, :]).plot(
+ summed, axes=[energy], units="eV",
+ color="#4fc3f7", label="summed")
+
+fig6.add_subplot(gs6[1, 0]).plot(
+ diff1, axes=[energy], units="eV",
+ color="#ff7043", label="Δ channel 1")
+
+fig6.add_subplot(gs6[1, 1]).plot(
+ diff2, axes=[energy], units="eV",
+ color="#aed581", label="Δ channel 2")
+
+fig6 # Interactive
diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py
new file mode 100644
index 00000000..c568aff6
--- /dev/null
+++ b/Examples/PlotTypes/plot_image2d.py
@@ -0,0 +1,130 @@
+"""
+2D Image with Histogram
+=======================
+
+Display a 2-D image with physical axes, a colourmap, and an interactive
+histogram below — all wired together with draggable threshold widgets.
+
+Layout
+------
+A :class:`~anyplotlib.GridSpec` with two rows puts the image
+on top and a bar-chart histogram below. Two
+:class:`~anyplotlib.widgets.VLineWidget` handles on the histogram mark the
+``display_min`` / ``display_max`` thresholds; dragging them updates the
+image colour scale in real time.
+
+Key bindings on the image panel: **R** reset view · **C** toggle colorbar ·
+**L** / **S** cycle colour-scale modes.
+
+New ``imshow`` parameters
+-------------------------
+``cmap``
+ Colormap name passed directly to :meth:`~anyplotlib.Axes.imshow`
+ (e.g. ``"viridis"``, ``"inferno"``). Defaults to ``"gray"``.
+``vmin`` / ``vmax``
+ Colormap clipping limits in data units. Values outside the range are
+ clamped to the colormap endpoints. Defaults to the data min/max.
+``origin``
+ ``"upper"`` (default) places row 0 at the top (image convention).
+ ``"lower"`` places row 0 at the bottom (scientific / matrix convention)
+ and automatically reverses the y-axis so tick values increase upward.
+"""
+import numpy as np
+import anyplotlib as apl
+
+
+rng = np.random.default_rng(1)
+
+# ── Synthetic diffraction pattern ─────────────────────────────────────────────
+N = 256
+x = np.linspace(-5, 5, N) # physical axis in nm
+y = np.linspace(-5, 5, N)
+XX, YY = np.meshgrid(x, y)
+R = np.sqrt(XX ** 2 + YY ** 2)
+
+
+def _ring(r, r0, width, amp):
+ return amp * np.exp(-0.5 * ((r - r0) / width) ** 2)
+
+
+image = (
+ _ring(R, 0.0, 0.30, 1.00) # central spot
+ + _ring(R, 2.1, 0.15, 0.55) # first-order ring
+ + _ring(R, 4.2, 0.15, 0.25) # second-order ring
+ + rng.normal(scale=0.04, size=(N, N))
+)
+
+# ── Layout: image (top, 3×) + histogram bar chart (bottom, 1×) ────────────────
+gs = apl.GridSpec(2, 1, height_ratios=[3, 1])
+fig = apl.Figure(figsize=(500, 640))
+ax_img = fig.add_subplot(gs[0, 0])
+ax_hist = fig.add_subplot(gs[1, 0])
+
+# ── Image panel — cmap, vmin, vmax supplied directly to imshow ────────────────
+vmin_init = float(image.min())
+vmax_init = float(image.max())
+
+# Pass cmap, vmin, and vmax directly — no separate set_colormap / set_clim call
+# needed for the initial display.
+v = ax_img.imshow(image, axes=[x, y], units="nm",
+ cmap="inferno", vmin=vmin_init, vmax=vmax_init)
+
+# First-order spot markers in the same physical coordinates used by imshow
+spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0],
+ [ 0.0, 2.1], [ 0.0, -2.1]])
+v.add_circles(spot_nm, name="spots", radius=7,
+ edgecolors="#00e5ff", facecolors="#00e5ff22",
+ labels=["g1", "g1_bar", "g2", "g2_bar"])
+
+# ── Histogram bar chart ────────────────────────────────────────────────────────
+counts, edges = np.histogram(image.ravel(), bins=64)
+bin_centers = 0.5 * (edges[:-1] + edges[1:])
+
+h = ax_hist.bar(counts, x_centers=bin_centers, orient="v",
+ color="#4fc3f7", y_units="count")
+
+# ── Draggable threshold handles on the histogram ──────────────────────────────
+wlo = h.add_vline_widget(vmin_init, color="#ff6e40") # low-threshold handle
+whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle
+
+
+@wlo.add_event_handler("pointer_up")
+def _apply_low(event):
+ """Update image display_min when the low handle is released."""
+ v.set_clim(vmin=event.source.x)
+
+
+@whi.add_event_handler("pointer_up")
+def _apply_high(event):
+ """Update image display_max when the high handle is released."""
+ v.set_clim(vmax=event.source.x)
+
+
+fig # Interactive
+
+# %%
+# Adjust colour map and display range
+# ------------------------------------
+# :meth:`~anyplotlib.Plot2D.set_colormap` switches the palette;
+# :meth:`~anyplotlib.Plot2D.set_clim` adjusts the display range.
+# Both are equivalent to passing ``cmap`` / ``vmin`` / ``vmax`` at construction.
+
+v.set_colormap("viridis")
+v.set_clim(vmin=0.0, vmax=0.8)
+
+fig
+
+# %%
+# origin='lower' — scientific / matrix convention
+# ------------------------------------------------
+# Passing ``origin='lower'`` places row 0 of the data at the *bottom* of the
+# image, matching the matplotlib / scientific convention. The y-axis is
+# automatically reversed so tick values still increase upward.
+
+mat = np.arange(64, dtype=float).reshape(8, 8) # row 0 = small values
+
+fig2, ax2 = apl.subplots()
+v2 = ax2.imshow(mat, cmap="plasma", origin="lower")
+
+fig2 # Interactive
+
diff --git a/Examples/PlotTypes/plot_inset.py b/Examples/PlotTypes/plot_inset.py
new file mode 100644
index 00000000..0f88a4fe
--- /dev/null
+++ b/Examples/PlotTypes/plot_inset.py
@@ -0,0 +1,90 @@
+"""
+Inset Plots
+===========
+
+Floating informational sub-plots that overlay the main figure — useful for
+displaying supplementary data alongside a primary image, as seen in orientation
+mapping, phase analysis, and similar workflows.
+
+Each inset has a **title bar** with two buttons:
+
+* **−** (minimize) — collapses the inset to its title bar only.
+* **⤢** (maximize) — expands the inset to ~72 % of the figure, centred.
+ Click **⤡** to restore.
+
+Multiple insets sharing the same ``corner`` auto-stack so they never overlap
+in the minimised or normal state.
+
+Python-side state can also be set programmatically::
+
+ inset.minimize()
+ inset.maximize()
+ inset.restore()
+ print(inset.inset_state) # "normal" | "minimized" | "maximized"
+"""
+
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(42)
+
+# ── Helpers — synthetic data ──────────────────────────────────────────────────
+
+def _diffraction(N=256):
+ """Simulated diffraction pattern (Gaussian rings)."""
+ y, x = np.ogrid[-N//2:N//2, -N//2:N//2]
+ r = np.hypot(x, y)
+ img = np.zeros((N, N))
+ for r0, sigma, amp in [(40, 6, 1.0), (80, 8, 0.6), (120, 10, 0.3)]:
+ img += amp * np.exp(-((r - r0) ** 2) / (2 * sigma ** 2))
+ img += rng.normal(0, 0.04, img.shape)
+ return img
+
+def _phase_map(N=128):
+ """Fake two-phase orientation map."""
+ img = rng.integers(0, 4, (N, N), dtype=np.uint8)
+ # blob of phase 2 in the centre
+ cy, cx = N // 2, N // 2
+ yy, xx = np.ogrid[:N, :N]
+ img[((yy - cy)**2 + (xx - cx)**2) < (N // 4)**2] = np.uint8(5)
+ return img.astype(float)
+
+def _pole_figure(N=96):
+ """Simulated pole-figure intensity (radial Gaussian blob)."""
+ y, x = np.ogrid[-N//2:N//2, -N//2:N//2]
+ r = np.hypot(x, y)
+ return np.exp(-(r ** 2) / (2 * (N // 6) ** 2)) + rng.normal(0, 0.02, (N, N))
+
+def _virtual_adf(N=128):
+ """Annular dark-field signal for a simple lattice."""
+ y, x = np.mgrid[:N, :N]
+ return (np.sin(y * 0.4) * np.cos(x * 0.4)) ** 2 + rng.normal(0, 0.05, (N, N))
+
+# ── Build figure ──────────────────────────────────────────────────────────────
+
+fig, ax = apl.subplots(1, 1, figsize=(660, 500))
+
+# Primary large image: diffraction pattern
+main = ax.imshow(_diffraction(256), cmap="inferno")
+
+# ── Inset 1: phase map (top-right) ───────────────────────────────────────────
+inset_phase = fig.add_inset(0.27, 0.27, corner="top-right", title="Phase Map")
+inset_phase.imshow(_phase_map(128), cmap="tab10")
+
+# ── Inset 2: pole figure — stacks below inset 1 in the same corner ────────────
+inset_pole = fig.add_inset(0.27, 0.27, corner="top-right", title="Pole Figure")
+inset_pole.imshow(_pole_figure(96), cmap="hot")
+
+# ── Inset 3: virtual ADF (bottom-left) ────────────────────────────────────────
+inset_adf = fig.add_inset(0.27, 0.27, corner="bottom-left", title="Virtual ADF")
+inset_adf.imshow(_virtual_adf(128), cmap="gray")
+
+# ── Inset 4: 1-D line profile (bottom-right) ─────────────────────────────────
+x_nm = np.linspace(0, 10, 256)
+profile = np.sin(x_nm * 3.5) * np.exp(-x_nm * 0.18) + rng.normal(0, 0.05, 256)
+
+inset_line = fig.add_inset(0.30, 0.22, corner="bottom-right", title="Line Profile")
+inset_line.plot(profile, axes=[x_nm], units="nm", color="#4fc3f7", linewidth=1.5)
+
+fig
+
diff --git a/Examples/PlotTypes/plot_label_formatting.py b/Examples/PlotTypes/plot_label_formatting.py
new file mode 100644
index 00000000..e6bbd320
--- /dev/null
+++ b/Examples/PlotTypes/plot_label_formatting.py
@@ -0,0 +1,42 @@
+"""
+Label Sizes and Scientific (TeX) Formatting
+===========================================
+
+Axis labels, titles, and the colorbar label accept an optional ``fontsize``
+(in CSS pixels) and support a small TeX subset inside ``$...$`` for
+scientific notation — superscripts, subscripts, Greek letters, and common
+symbols — rendered directly on the canvas with no MathJax dependency:
+
+* ``$10^{-3}$``, ``$x^2$`` — exponents
+* ``$E_F$``, ``$k_{B}T$`` — subscripts
+* ``$\\alpha$ … $\\Omega$``, ``\\mu``, ``\\Delta`` — Greek letters
+* ``\\times``, ``\\pm``, ``\\AA``, ``\\degree``, ``\\propto``, ``\\partial`` — symbols
+* ``$\\mathrm{...}$`` — upright text inside math
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(7)
+
+fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(880, 380))
+
+# ── 2-D panel: diffraction-style image with TeX axis labels ────────────────
+data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
+q = np.linspace(-2.5, 2.5, 128)
+img = ax_img.imshow(data, axes=[q, q], units="")
+img.set_title(r"$|F(q)|^2$", fontsize=12)
+img.set_xlabel(r"$q_x$ ($\AA^{-1}$)", fontsize=13)
+img.set_ylabel(r"$q_y$ ($\AA^{-1}$)", fontsize=13)
+img.set_colorbar_visible(True)
+img.set_colorbar_label(r"Counts $\times 10^{3}$")
+
+# ── 1-D panel: spectrum with sized, TeX-formatted labels ───────────────────
+energy = np.linspace(0, 3, 512)
+spectrum = np.exp(-((energy - 1.2) / 0.15) ** 2) + 0.05 * rng.random(512)
+spec = ax_spec.plot(spectrum, axes=[energy], color="#ff7043")
+spec.set_title(r"Plasmon peak near $E_p$", fontsize=12)
+spec.set_xlabel(r"$\Delta E$ (eV)", fontsize=12)
+spec.set_ylabel(r"Intensity ($10^{-3}$ counts)", fontsize=12)
+spec.set_tick_label_size(11)
+
+fig
diff --git a/Examples/PlotTypes/plot_line_styles.py b/Examples/PlotTypes/plot_line_styles.py
new file mode 100644
index 00000000..2c38c527
--- /dev/null
+++ b/Examples/PlotTypes/plot_line_styles.py
@@ -0,0 +1,159 @@
+"""
+1D Line Styles
+==============
+
+Demonstrates the line-style, opacity, and per-point marker parameters
+available on :meth:`~anyplotlib.Axes.plot` and
+:meth:`~anyplotlib.Plot1D.add_line`.
+
+Four separate figures are shown:
+
+1. **Linestyles** – all four dash patterns on one panel with a legend.
+2. **Alpha (transparency)** – two overlapping sine waves, each at 40 % opacity.
+3. **Marker symbols** – all seven supported symbols, each on its own offset
+ curve.
+4. **Combined** – dashed + semi-transparent + circle-marker overlay on a solid
+ primary line; demonstrates post-construction setters.
+"""
+import numpy as np
+import anyplotlib as apl
+
+t256 = np.linspace(0.0, 2.0 * np.pi, 256) # dense — good for dashes / alpha
+t24 = np.linspace(0.0, 2.0 * np.pi, 24) # sparse — makes markers visible
+
+# ── 1. Linestyles ─────────────────────────────────────────────────────────────
+fig1, ax1 = apl.subplots(1, 1, figsize=(580, 300))
+
+plot1 = ax1.plot(np.sin(t256), color="#4fc3f7", linewidth=2,
+ linestyle="solid", label="solid")
+plot1.add_line(np.sin(t256) + 0.6, color="#ff7043", linewidth=2,
+ linestyle="dashed", label="dashed (\"--\")")
+plot1.add_line(np.sin(t256) + 1.2, color="#aed581", linewidth=2,
+ linestyle="dotted", label="dotted (\":\")")
+plot1.add_line(np.sin(t256) + 1.8, color="#ce93d8", linewidth=2,
+ linestyle="dashdot", label="dashdot (\"-.\")")
+
+fig1
+
+# %%
+# The ``ls`` shorthand
+# --------------------
+# Each linestyle has a single-character (or two-character) shorthand that
+# matches the matplotlib convention:
+#
+# * ``"-"`` → ``"solid"``
+# * ``"--"`` → ``"dashed"``
+# * ``":"`` → ``"dotted"``
+# * ``"-."`` → ``"dashdot"``
+#
+# The shorthands work on both :meth:`~anyplotlib.Axes.plot`
+# and :meth:`~anyplotlib.Plot1D.add_line`:
+
+fig2a, ax2a = apl.subplots(1, 1, figsize=(440, 220))
+p = ax2a.plot(np.sin(t256), ls="-", color="#4fc3f7", label='ls="-"')
+p.add_line(np.sin(t256) + 0.8, ls="--", color="#ff7043", label='ls="--"')
+p.add_line(np.sin(t256) + 1.6, ls=":", color="#aed581", label='ls=":"')
+fig2a
+
+# %%
+# Alpha (opacity)
+# ---------------
+# ``alpha`` controls line opacity on a 0–1 scale. Values below 1 let
+# overlapping curves show through each other — useful for comparing signals
+# that share the same amplitude range.
+
+fig2, ax2 = apl.subplots(1, 1, figsize=(580, 300))
+
+plot2 = ax2.plot(np.sin(t256), color="#4fc3f7", alpha=0.4, linewidth=3,
+ label="sin α=0.4")
+plot2.add_line(np.cos(t256), color="#ff7043", alpha=0.4, linewidth=3,
+ label="cos α=0.4")
+
+fig2
+
+# %%
+# Marker symbols
+# --------------
+# Set ``marker`` to place a symbol at every data point. Use a **sparse**
+# x-axis (few points) so the individual markers are legible.
+# ``markersize`` is the radius (circles / diamonds) or half-side-length
+# (squares, triangles) in canvas pixels.
+#
+# Supported symbols:
+#
+# * ``"o"`` — circle
+# * ``"s"`` — square
+# * ``"^"`` — triangle-up
+# * ``"v"`` — triangle-down
+# * ``"D"`` — diamond
+# * ``"+"`` — plus (stroke-only)
+# * ``"x"`` — cross (stroke-only)
+# * ``"none"`` — no marker (default)
+
+SYMBOLS = [
+ ("o", "#4fc3f7"),
+ ("s", "#ff7043"),
+ ("^", "#aed581"),
+ ("v", "#ce93d8"),
+ ("D", "#ffcc02"),
+ ("+", "#80cbc4"),
+ ("x", "#ef9a9a"),
+]
+
+fig3, ax3 = apl.subplots(1, 1, figsize=(580, 380))
+
+plot3 = ax3.plot(
+ np.sin(t24) + (0 - 3) * 0.9,
+ color=SYMBOLS[0][1], linewidth=1.5,
+ marker=SYMBOLS[0][0], markersize=5,
+ label=f'marker="{SYMBOLS[0][0]}"',
+)
+for i, (sym, col) in enumerate(SYMBOLS[1:], 1):
+ plot3.add_line(
+ np.sin(t24) + (i - 3) * 0.9,
+ color=col, linewidth=1.5,
+ marker=sym, markersize=5,
+ label=f'marker="{sym}"',
+ )
+
+fig3
+
+# %%
+# Combined — linestyle + alpha + marker
+# --------------------------------------
+# All three style parameters can be combined freely on the same line or on
+# separate overlay lines.
+
+fig4, ax4 = apl.subplots(1, 1, figsize=(580, 300))
+
+# Dense solid primary line
+plot4 = ax4.plot(np.sin(t256), color="#4fc3f7", linewidth=2,
+ label="sin (solid)")
+
+# Sparse dashed overlay with circle markers and reduced opacity
+plot4.add_line(np.cos(t24), color="#ff7043", linewidth=2,
+ linestyle="dashed", alpha=0.75,
+ marker="o", markersize=5,
+ label="cos (dashed, α=0.75, marker='o')")
+
+fig4
+
+# %%
+# Post-construction setters
+# -------------------------
+# Every primary-line style property has a matching setter method. These
+# mutate ``_state`` and push the change to the canvas immediately — no
+# need to recreate the panel.
+
+fig5, ax5 = apl.subplots(1, 1, figsize=(440, 220))
+plot5 = ax5.plot(np.sin(t256), color="#4fc3f7", linewidth=1.5)
+
+# Change style via setters
+plot5.set_color("#ff7043")
+plot5.set_linewidth(2.5)
+plot5.set_linestyle("dashdot") # equivalent: plot5.set_linestyle("-.")
+plot5.set_alpha(0.8)
+plot5.set_marker("o", markersize=5)
+
+fig5
+
diff --git a/Examples/plot_pcolormesh.py b/Examples/PlotTypes/plot_pcolormesh.py
similarity index 89%
rename from Examples/plot_pcolormesh.py
rename to Examples/PlotTypes/plot_pcolormesh.py
index a9d298ba..c5a79b74 100644
--- a/Examples/plot_pcolormesh.py
+++ b/Examples/PlotTypes/plot_pcolormesh.py
@@ -2,17 +2,17 @@
pcolormesh — non-linear axes
============================
-Demonstrate :meth:`~anyplotlib.figure_plots.Axes.pcolormesh` with non-uniform
+Demonstrate :meth:`~anyplotlib.Axes.pcolormesh` with non-uniform
(log-spaced) x-edges and irregularly-spaced y-edges, mirroring
``matplotlib.axes.Axes.pcolormesh``.
-The key difference from :meth:`~anyplotlib.figure_plots.Axes.imshow` is that
+The key difference from :meth:`~anyplotlib.Axes.imshow` is that
``pcolormesh`` takes **edge** arrays (length N+1 and M+1 for an (M, N) data
-array) rather than centre arrays. This enables fully non-linear axes where
+array) rather than center arrays. This enables fully non-linear axes where
each cell can have a different width/height in data coordinates.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(42)
@@ -36,7 +36,7 @@
[y_centres[-1] + (y_centres[-1] - y_centres[-2]) / 2]])
# ── Plot ──────────────────────────────────────────────────────────────────────
-fig, ax = vw.subplots(1, 1, figsize=(560, 460))
+fig, ax = apl.subplots(1, 1, figsize=(560, 460))
mesh = ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.")
mesh.set_colormap("viridis")
fig
diff --git a/Examples/PlotTypes/plot_spectra1d.py b/Examples/PlotTypes/plot_spectra1d.py
new file mode 100644
index 00000000..347565dc
--- /dev/null
+++ b/Examples/PlotTypes/plot_spectra1d.py
@@ -0,0 +1,112 @@
+"""
+1D Spectra
+==========
+
+Plot a 1-D spectrum with a physical x-axis (energy in eV) using
+:meth:`~anyplotlib.Axes.plot`.
+
+The spectrum contains a broad background and three Gaussian peaks.
+Circle markers highlight the peak positions using
+:meth:`~anyplotlib.Plot1D.add_points`, and a range widget
+selects a region of interest. A model fit is overlaid with a dashed line,
+and the background component is shown as a semi-transparent dotted curve with
+diamond markers.
+
+Pan and zoom with the mouse; press **R** to reset the view.
+"""
+import numpy as np
+import anyplotlib as apl
+
+rng = np.random.default_rng(0)
+
+# ── Synthetic XPS-style spectrum ──────────────────────────────────────────────
+energy = np.linspace(280, 295, 512) # binding energy axis (eV)
+
+def gaussian(x, mu, sigma, amp):
+ return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
+
+background = 0.4 * np.exp(-0.08 * (energy - 280))
+
+# Background + three peaks (C 1s region)
+spectrum = (
+ background
+ + gaussian(energy, 284.8, 0.4, 1.0) # C–C / C–H
+ + gaussian(energy, 286.2, 0.4, 0.35) # C–O
+ + gaussian(energy, 288.0, 0.4, 0.18) # C=O
+ + rng.normal(scale=0.015, size=len(energy))
+)
+
+# ── Plot ──────────────────────────────────────────────────────────────────────
+fig, ax = apl.subplots(1, 1, figsize=(620, 340))
+v = ax.plot(spectrum, axes=[energy], units="eV", y_units="Intensity (a.u.)",
+ color="#4fc3f7", linewidth=1.5)
+
+# ── Peak markers (add_points collection) ──────────────────────────────────────
+peak_energies = np.array([284.8, 286.2, 288.0])
+peak_offsets = np.column_stack([
+ peak_energies,
+ np.interp(peak_energies, energy, spectrum),
+])
+v.add_points(peak_offsets, name="peaks",
+ sizes=7, color="#ff1744", facecolors="#ff174433",
+ labels=["C\u2013C", "C\u2013O", "C=O"])
+
+# ── Region-of-interest widget ─────────────────────────────────────────────────
+v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff")
+
+fig
+
+# %%
+# Overlay a model fit — linestyle and alpha
+# -----------------------------------------
+# Use :meth:`~anyplotlib.Plot1D.add_line` to overlay additional
+# curves. Here the noiseless model fit is drawn as a **dashed** line so it
+# is visually distinct from the noisy measured spectrum. The ``alpha``
+# parameter makes the fit semi-transparent so the data underneath remains
+# readable.
+#
+# The y-axis range is expanded automatically to accommodate any overlay line
+# whose values fall outside the current bounds.
+
+fit = (
+ background
+ + gaussian(energy, 284.8, 0.4, 1.0)
+ + gaussian(energy, 286.2, 0.4, 0.35)
+ + gaussian(energy, 288.0, 0.4, 0.18)
+)
+v.add_line(fit, x_axis=energy,
+ color="#ffcc00", linewidth=2.0,
+ linestyle="dashed", alpha=0.85,
+ label="fit")
+
+fig
+
+# %%
+# Background component — dotted line with markers
+# ------------------------------------------------
+# Draw the exponential background component as a **dotted** curve. Passing
+# ``marker="D"`` places a diamond at every data point (useful when the line
+# is sparse or when you want to emphasise individual sample positions).
+# ``markersize`` controls the half-size of the symbol in pixels.
+
+# Sub-sample to keep the marker plot readable
+step = 32
+v.add_line(background[::step], x_axis=energy[::step],
+ color="#ce93d8", linewidth=1.2,
+ linestyle="dotted", alpha=0.9,
+ marker="D", markersize=3,
+ label="background")
+
+fig
+
+# %%
+# Post-construction setters
+# -------------------------
+# All primary-line style properties can be changed after the panel is created
+# without rebuilding it. This is useful in interactive notebooks where you
+# want to tweak the appearance of the main trace.
+
+v.set_alpha(0.9) # slightly reduce primary-line opacity
+v.set_linewidth(2.0) # thicker stroke for the main spectrum
+
+fig
diff --git a/Examples/Widgets/plot_widget1d_hline.py b/Examples/Widgets/plot_widget1d_hline.py
index 8cfb70e1..e753163e 100644
--- a/Examples/Widgets/plot_widget1d_hline.py
+++ b/Examples/Widgets/plot_widget1d_hline.py
@@ -3,18 +3,18 @@
==========================
A draggable horizontal line on a 1-D plot panel.
-Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_hline_widget`.
+Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_hline_widget`.
Drag the line up or down to change the selected y value.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
x = np.linspace(0, 4 * np.pi, 512)
signal = np.sin(x)
-fig, ax = vw.subplots(1, 1, figsize=(560, 300))
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
v = ax.plot(signal, axes=[x], units="rad")
v.add_hline_widget(y=0.5, color="#69f0ae")
-fig
+fig
diff --git a/Examples/Widgets/plot_widget1d_range.py b/Examples/Widgets/plot_widget1d_range.py
index 7eecdf3c..0a9ab0c8 100644
--- a/Examples/Widgets/plot_widget1d_range.py
+++ b/Examples/Widgets/plot_widget1d_range.py
@@ -3,19 +3,19 @@
================
A draggable range selector on a 1-D plot panel with two handles.
-Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_range_widget`.
+Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_range_widget`.
Drag either handle to resize the selected interval, or drag the band
to move it.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
x = np.linspace(0, 4 * np.pi, 512)
signal = np.sin(x)
-fig, ax = vw.subplots(1, 1, figsize=(560, 300))
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
v = ax.plot(signal, axes=[x], units="rad")
v.add_range_widget(x0=np.pi, x1=2 * np.pi, color="#ffeb3b")
-fig
+fig
diff --git a/Examples/Widgets/plot_widget1d_vline.py b/Examples/Widgets/plot_widget1d_vline.py
index 88debdb9..7699f479 100644
--- a/Examples/Widgets/plot_widget1d_vline.py
+++ b/Examples/Widgets/plot_widget1d_vline.py
@@ -3,17 +3,18 @@
========================
A draggable vertical line on a 1-D plot panel.
-Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_vline_widget`.
+Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_vline_widget`.
Drag the line left or right to change the selected x position.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
x = np.linspace(0, 4 * np.pi, 512)
signal = np.sin(x)
-fig, ax = vw.subplots(1, 1, figsize=(560, 300))
+fig, ax = apl.subplots(1, 1, figsize=(560, 300))
v = ax.plot(signal, axes=[x], units="rad")
v.add_vline_widget(x=np.pi, color="#e040fb")
+
fig
diff --git a/Examples/Widgets/plot_widget2d_annular.py b/Examples/Widgets/plot_widget2d_annular.py
index b79faa6e..f15b156f 100644
--- a/Examples/Widgets/plot_widget2d_annular.py
+++ b/Examples/Widgets/plot_widget2d_annular.py
@@ -6,16 +6,16 @@
Drag the inner or outer ring to adjust the radii.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(2)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("annular", color="#00e5ff", cx=64, cy=64, r_outer=40, r_inner=20)
-fig
+fig
diff --git a/Examples/Widgets/plot_widget2d_circle.py b/Examples/Widgets/plot_widget2d_circle.py
index afb0d62a..589c6906 100644
--- a/Examples/Widgets/plot_widget2d_circle.py
+++ b/Examples/Widgets/plot_widget2d_circle.py
@@ -3,21 +3,20 @@
=================
A draggable, resizable circle overlay on a 2-D image panel.
-Add it with :meth:`~anyplotlib.figure_plots.Plot2D.add_widget` using
-``kind="circle"``, or via the convenience wrapper
-``add_widget("circle", ...)``.
+Add it with :meth:`~anyplotlib.plot2d.Plot2D.add_widget` using
+``kind="circle"``.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(0)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("circle", color="#e040fb", cx=64, cy=64, r=20)
-fig
+fig
diff --git a/Examples/Widgets/plot_widget2d_crosshair.py b/Examples/Widgets/plot_widget2d_crosshair.py
index 3b29b0a6..6352125c 100644
--- a/Examples/Widgets/plot_widget2d_crosshair.py
+++ b/Examples/Widgets/plot_widget2d_crosshair.py
@@ -6,16 +6,16 @@
on a 2-D image panel.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(3)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("crosshair", color="#69f0ae", cx=64, cy=64)
-fig
+fig
diff --git a/Examples/Widgets/plot_widget2d_label.py b/Examples/Widgets/plot_widget2d_label.py
index 30055587..30fc2ab2 100644
--- a/Examples/Widgets/plot_widget2d_label.py
+++ b/Examples/Widgets/plot_widget2d_label.py
@@ -7,16 +7,17 @@
and ``fontsize``.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(5)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("label", color="#ff1744", x=10, y=10,
text="Region A", fontsize=14)
+
fig
diff --git a/Examples/Widgets/plot_widget2d_polygon.py b/Examples/Widgets/plot_widget2d_polygon.py
index 2a1ab9e7..fa3d2de2 100644
--- a/Examples/Widgets/plot_widget2d_polygon.py
+++ b/Examples/Widgets/plot_widget2d_polygon.py
@@ -6,18 +6,18 @@
Pass ``vertices`` as a list of ``[x, y]`` pixel coordinates.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(4)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("polygon", color="#ff9100",
vertices=[[32, 16], [96, 16], [112, 80],
[64, 112], [16, 80]])
-fig
+fig
diff --git a/Examples/Widgets/plot_widget2d_rectangle.py b/Examples/Widgets/plot_widget2d_rectangle.py
index afa2a904..52579e56 100644
--- a/Examples/Widgets/plot_widget2d_rectangle.py
+++ b/Examples/Widgets/plot_widget2d_rectangle.py
@@ -3,20 +3,20 @@
====================
A draggable, resizable rectangle overlay on a 2-D image panel.
-Add it with :meth:`~anyplotlib.figure_plots.Plot2D.add_widget` using
+Add it with :meth:`~anyplotlib.plot2d.Plot2D.add_widget` using
``kind="rectangle"``.
"""
import numpy as np
-import anyplotlib as vw
+import anyplotlib as apl
rng = np.random.default_rng(1)
data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1)
data = (data - data.min()) / (data.max() - data.min())
xy = np.linspace(0, 10, 128)
-fig, ax = vw.subplots(1, 1, figsize=(460, 460))
+fig, ax = apl.subplots(1, 1, figsize=(460, 460))
v = ax.imshow(data, axes=[xy, xy], units="nm")
v.add_widget("rectangle", color="#ffeb3b", x=24, y=24, w=80, h=60)
-fig
+fig
diff --git a/Examples/plot_image2d.py b/Examples/plot_image2d.py
deleted file mode 100644
index 758e37cf..00000000
--- a/Examples/plot_image2d.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-2D Image
-========
-Display a 2-D image with physical axes using
-:meth:`~anyplotlib.figure_plots.Axes.imshow`.
-The image is a synthetic STEM-like diffraction pattern with a physical
-length scale in nanometres. Circle markers highlight the first-order
-diffraction spots, and an annular integration widget is placed over the
-central beam. Pan and zoom with the mouse; press **R** to reset the view,
-**H** to toggle the histogram, **L** / **S** to cycle colour-scale modes.
-"""
-import numpy as np
-import anyplotlib as vw
-rng = np.random.default_rng(1)
-# ── Synthetic diffraction pattern ─────────────────────────────────────────────
-N = 256
-x = np.linspace(-5, 5, N) # physical axis in nm
-y = np.linspace(-5, 5, N)
-XX, YY = np.meshgrid(x, y)
-R = np.sqrt(XX ** 2 + YY ** 2)
-def _ring(r, r0, width, amp):
- return amp * np.exp(-0.5 * ((r - r0) / width) ** 2)
-image = (
- _ring(R, 0.0, 0.30, 1.00) # central spot
- + _ring(R, 2.1, 0.15, 0.55) # first-order ring
- + _ring(R, 4.2, 0.15, 0.25) # second-order ring
- + rng.normal(scale=0.04, size=(N, N))
-)
-# ── Plot ───────────────────────────────────────────────────────────────────────
-fig, ax = vw.subplots(1, 1, figsize=(500, 500))
-v = ax.imshow(image, axes=[x, y], units="nm")
-v.set_colormap("inferno")
-# ── First-order spot markers ───────────────────────────────────────────────────
-# imshow axes are centre arrays: pixel = (phys - x[0]) / (x[1] - x[0])
-dx = x[1] - x[0]
-def phys_to_px(val):
- return (np.asarray(val) - x[0]) / dx
-spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0],
- [ 0.0, 2.1], [ 0.0, -2.1]])
-spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]),
- phys_to_px(spot_nm[:, 1])])
-v.add_circles(spot_px, name="spots", radius=7,
- edgecolors="#00e5ff", facecolors="#00e5ff22",
- labels=["g1", "g1_bar", "g2", "g2_bar"])
-# ── Annular integration widget ─────────────────────────────────────────────────
-cx = cy = float(phys_to_px(0.0))
-v.add_widget("annular", color="#ffcc00",
- cx=cx, cy=cy,
- r_outer=float(phys_to_px(2.8) - phys_to_px(0.0)),
- r_inner=float(phys_to_px(1.2) - phys_to_px(0.0)))
-fig
-# %%
-# Adjust display range and colour map
-# -------------------------------------
-# :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` clips the colour scale;
-# :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette.
-v.set_clim(vmin=0.0, vmax=0.8)
-v.set_colormap("viridis")
-fig
diff --git a/Examples/plot_spectra1d.py b/Examples/plot_spectra1d.py
deleted file mode 100644
index eac54435..00000000
--- a/Examples/plot_spectra1d.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-1D Spectra
-==========
-
-Plot a 1-D spectrum with a physical x-axis (energy in eV) using
-:meth:`~anyplotlib.figure_plots.Axes.plot`.
-
-The spectrum contains a broad background and three Gaussian peaks.
-Vertical-line markers highlight the peak positions, and a range widget
-selects a region of interest. Pan and zoom with the mouse; press **R**
-to reset the view.
-"""
-import numpy as np
-import anyplotlib as vw
-
-rng = np.random.default_rng(0)
-
-# ── Synthetic XPS-style spectrum ─────────────────────────────────────────────
-energy = np.linspace(280, 295, 512) # binding energy axis (eV)
-
-def gaussian(x, mu, sigma, amp):
- return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
-
-# Background + three peaks (C 1s region)
-spectrum = (
- 0.4 * np.exp(-0.08 * (energy - 280)) # exponential background
- + gaussian(energy, 284.8, 0.4, 1.0) # C–C / C–H
- + gaussian(energy, 286.2, 0.4, 0.35) # C–O
- + gaussian(energy, 288.0, 0.4, 0.18) # C=O
- + rng.normal(scale=0.015, size=len(energy))
-)
-
-# ── Plot ──────────────────────────────────────────────────────────────────────
-fig, ax = vw.subplots(1, 1, figsize=(620, 320))
-v = ax.plot(spectrum, axes=[energy], units="eV", y_units="Intensity (a.u.)")
-
-# ── Peak markers ──────────────────────────────────────────────────────────────
-peak_energies = np.array([284.8, 286.2, 288.0])
-peak_offsets = np.column_stack([
- peak_energies,
- np.interp(peak_energies, energy, spectrum),
-])
-v.add_points(peak_offsets, name="peaks",
- edgecolors="#ff1744", facecolors="#ff174433", sizes=7,
- labels=["C–C", "C–O", "C=O"])
-
-# ── Region-of-interest widget ─────────────────────────────────────────────────
-v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff")
-
-fig
-
-# %%
-# Overlay a second spectrum
-# -------------------------
-# Use :meth:`~anyplotlib.figure_plots.Plot1D.add_line` to overlay additional
-# curves — useful for comparing reference spectra or fits.
-
-fit = (
- 0.4 * np.exp(-0.08 * (energy - 280))
- + gaussian(energy, 284.8, 0.4, 1.0)
- + gaussian(energy, 286.2, 0.4, 0.35)
- + gaussian(energy, 288.0, 0.4, 0.18)
-)
-v.add_line(fit, x_axis=energy, color="#ffcc00", linewidth=1.5, label="fit")
-fig
-
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..115f7b00
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Carter Francis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
index b97de95f..0005703e 100644
--- a/Makefile
+++ b/Makefile
@@ -18,3 +18,8 @@ help:
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+clean:
+ rm -rf $(BUILDDIR)/*
+ rm -rf docs/auto_examples/
+ rm -rf docs/api/generated/
diff --git a/ORIX_BACKEND_PLAN.md b/ORIX_BACKEND_PLAN.md
new file mode 100644
index 00000000..74f80787
--- /dev/null
+++ b/ORIX_BACKEND_PLAN.md
@@ -0,0 +1,133 @@
+# Making anyplotlib a plotting backend for orix
+
+Goal: render orix's IPF / stereographic / pole-figure plots **natively in
+anyplotlib** so orix (and SpyDE) can drop matplotlib for them.
+
+## How orix plots today (matplotlib)
+
+orix builds everything on **matplotlib Axes subclasses + registered projections**:
+
+- `orix/plot/stereographic_plot.py` — `StereographicPlot(name="stereographic")`
+ subclasses `matplotlib.axes.Axes`. It overrides `plot` / `scatter` / `text` to
+ first project spherical → (x, y) via `orix.projections.StereographicProjection`
+ (pure numpy, **no matplotlib**), then call `super().plot/scatter/text(x, y, …)`
+ in **data coordinates** with `set_aspect("equal")`.
+- `inverse_pole_figure_plot.py` — `InversePoleFigurePlot` (subclass of the above)
+ draws the fundamental-sector outline + `[hkl]` corner labels.
+- `IPFColorKeyTSL.plot()` — fills the sector with colour (scatter / mesh of
+ projected directions) on such an axis.
+
+So the orix side that's *not* matplotlib is just the **projection math**
+(`vector2xy`); the rendering is plain 2-D matplotlib: `scatter`, `plot`, `text`,
+`fill`/patches, in **data coords**, aspect-equal.
+
+## The gap in anyplotlib
+
+anyplotlib's 2-D is **image-centric**. Marker/overlay groups (`add_points`,
+`add_lines`, `add_polygons`, `add_texts`, `add_circles`) with `transform="data"`
+map offsets through **`_imgFitRect` (image pixels)** — i.e. an offset `(x, y)` is
+treated as image column/row, *not* the axis's `x_axis`/`y_axis` data values
+(confirmed in `figure_esm.js`: all 2-D coordinate fns derive from `_imgFitRect`;
+markers use it, never `st.x_axis`). There is no "blank axis with x/y limits +
+data-coord scatter/line/polygon/text" surface — which is exactly what orix needs.
+
+(This is also why the SpyDE IPF-refine triangle is currently a matplotlib raster:
+its overlays in stereographic coords collapsed into the image's top-left corner.)
+
+## Staged plan
+
+**Stage 1 — data-coordinate overlays for `Plot2D` (foundation).**
+Make marker groups honour the panel's `x_axis`/`y_axis` when present: a
+`transform="data"` offset `(x, y)` maps via the axis values → image fraction →
+canvas (the matplotlib `imshow(extent=…)` + `scatter` alignment). Smallest change
+that (a) unblocks a fully-native IPF triangle over the heatmap imshow and (b)
+proves the data→pixel plumbing. Touches `markers.py` (wire) + `figure_esm.js`
+(`drawMarkers2d` coord branch). Demo/test: native IPF heatmap triangle.
+
+**Stage 2 — a coordinate-only 2-D axis (no image).**
+`ax.set_xlim/ylim` + `set_aspect("equal")` on a panel with **no imshow**, where
+`add_points/lines/polygons/texts` live in data coords. This is the general
+"matplotlib-Axes-like 2-D" surface orix's `StereographicPlot` draws onto. Likely
+a lightweight `Plot2DCoords` (or extend `Plot2D` to allow `data_bounds` without an
+image) reusing the Stage-1 transform.
+
+**Stage 3 — orix targets anyplotlib (lives in ORIX, not here).**
+The stereographic projection + IPF / pole-figure plotting **belongs in orix** and
+already exists there (`StereographicProjection`, `StereographicPlot`,
+`IPFColorKeyTSL`). anyplotlib stays domain-agnostic — it must NOT know about
+stereographic projections. The integration is an **orix-side** change: refactor
+orix's plotting to draw through a backend (matplotlib OR anyplotlib's `axes2d`
+surface) — `vector2xy` (orix) → `PlotXY.scatter/plot/fill/text` (anyplotlib).
+anyplotlib's only job is to be a complete-enough generic 2-D backend.
+
+## Align with matplotlib's model
+
+matplotlib's data drawing is two ideas we should mirror:
+
+1. **`transData` = a composed transform chain.** `transData = transScale (log/lin)
+ + transLimits (data Bbox → unit [0,1] box) + transAxes (unit box → display)`.
+ i.e. **data → [0,1] via the axis limits → pixels via the axes rect**.
+ `set_aspect("equal")` is `apply_aspect()` — it adjusts the box (and limits) so a
+ data unit is the same length on x and y.
+ - anyplotlib's **1-D path already does this**: marker offsets are normalised to
+ `[0,1]` by the x/y data range, then `_tc2d(fx,fy)=[r.x+fx*r.w, r.y+(1-fy)*r.h]`
+ maps the unit box → the panel rect. That's exactly `transLimits` → `transAxes`.
+ - So the coordinate axis just needs **explicit `xlim`/`ylim`** as the transData
+ domain + an aspect step, reusing the same unit-box→rect mapping.
+
+2. **Scatter is a `Collection` (offsets + per-point props), not N artists.**
+ `ax.scatter(x,y,c=,s=)` → one `PathCollection`: an offsets array drawn with a
+ shared marker path, per-point colours/sizes. anyplotlib's `MarkerGroup`
+ (`add_points`/`add_circles`) is **already this** — offsets + `facecolors`/
+ `sizes` arrays. So `ax.scatter` becomes a thin wrapper returning a points
+ MarkerGroup positioned via transData; `plot`→a polyline marker group (`Line2D`),
+ `fill`/polygons→`add_polygons` (`Polygon`/`PathCollection`), `text`→`add_texts`.
+
+So the coordinate axis = **the 1-D unit-box→rect transform driven by explicit
+xlim/ylim (+ aspect), with the existing collection-style markers as the artists** —
+semantically the same as matplotlib's `transData` + `PathCollection`.
+
+## API sketch (matplotlib-parity)
+
+```python
+ax = fig.add_axes2d() # blank data-coord axis (no image)
+ax.set_xlim(-1, 1); ax.set_ylim(-1, 1); ax.set_aspect("equal")
+ax.scatter(xs, ys, c=colors, s=8) # -> PathCollection-style MarkerGroup
+ax.plot(ex, ey, color="w") # -> Line2D-style polyline
+ax.fill(px, py, facecolor="…") # -> Polygon
+ax.text(x, y, r"$[111]$") # -> Text
+```
+
+## Status
+
+**Stage 2 landed:** `Axes.axes2d()` → `PlotXY` (`anyplotlib/plotxy/`). It reuses
+the 1-D data→canvas transform (`kind="1d"`, hidden curve) so `scatter`/`plot`/
+`fill`/`text` draw as collection markers in **data coords** with no renderer
+change. `set_xlim`/`set_ylim`/`set_aspect`. Tests in `tests/test_plotxy/`
+(5 pass incl. a chromium render); demo = a native IPF triangle (fill + scatter +
+labels in data coords).
+
+**Two renderer gaps the demo exposed — both now CLOSED:**
+1. **Per-point scatter colours — DONE.** `drawMarkers1d` `points` now reads
+ per-offset `facecolors`/`color` arrays (matplotlib `PathCollection`), so
+ `scatter(c=[...])` renders the IPF colour-key gradient.
+2. **`aspect="equal"` — DONE.** `_plotRect1d(p)` applies matplotlib
+ `apply_aspect`: when `state.aspect==='equal'` it shrinks + centres the panel
+ box so one data unit spans equal pixels on x and y. Baked into the shared rect
+ helper, so draw / markers / overlay / hit-test all use the identical adjusted
+ box (matplotlib's transData derives from the axes box). A wide-panel IPF
+ triangle now renders undistorted (`tests/test_plotxy`:
+ `test_aspect_equal_renders_square` vs `test_aspect_auto_fills_panel`).
+
+**Then the orix side (in the orix repo, not here):** the stereographic / IPF /
+pole-figure plotting STAYS in orix; refactor it to draw through a backend so
+`vector2xy` (orix) feeds `PlotXY.scatter/plot/fill/text` (anyplotlib). anyplotlib
+stays generic — finishing (1) + (2) makes it a complete-enough backend.
+
+## Recommendation
+
+Built **Stage 2 (chosen): the coordinate-only 2-D axis** as above —
+reuse the 1-D `transLimits→transAxes` unit-box transform with explicit xlim/ylim +
+aspect, expose `scatter`/`plot`/`fill`/`text` as collection-style artists. Demo =
+a native IPF fundamental-sector triangle (filled colour-key + outline + `[hkl]`
+labels) drawn purely with these primitives.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..92392321
--- /dev/null
+++ b/README.md
@@ -0,0 +1,119 @@
+# anyplotlib
+
+[](https://codecov.io/gh/CSSFrancis/anyplotlib)
+[](https://github.com/CSSFrancis/anyplotlib/actions/workflows/tests.yml)
+
+**anyplotlib** is a fast, interactive plotting library for Jupyter, built on
+[anywidget](https://anywidget.dev/) and a pure-JavaScript canvas renderer.
+It follows matplotlib's object-oriented API — create a `Figure`, call methods
+on `Axes` — so switching is often a one-line change:
+
+```python
+import anyplotlib as apl
+
+fig, ax = apl.subplots(1, 1) # same shape as plt.subplots(1, 1)
+ax.imshow(data) # pan, zoom, and inspect — live
+fig # display in a Jupyter cell
+```
+
+If you have used matplotlib's OO interface, you already know most of
+anyplotlib. What you gain is interactivity that stays fast on large data —
+without a kernel round-trip per frame.
+
+## Why another plotting library?
+
+Matplotlib is a superb tool for publication-quality static figures, but its
+interactive notebook story (`ipympl`) re-renders the whole figure on the
+Python side for every frame. anyplotlib makes the opposite trade-off:
+
+- **All rendering happens in the browser.** Python serialises compact state
+ (raw image bytes, base64-encoded float arrays) once; pan/zoom/drag never
+ touch the kernel.
+- **Each image, line collection, or marker group is a single canvas object**,
+ so blitting works and drag interactions run at full frame rate.
+- **The scope is deliberately limited.** The OO API only (no `plt.plot()`
+ global state), a curated set of plot types and marker styles, and raster
+ canvas output rather than vector graphics. For print-quality SVG/PDF
+ figures, matplotlib remains the right tool.
+
+## Features
+
+- **Plot types** — `plot` (1-D lines with markers, linestyles, legends, log y),
+ `imshow` (2-D images with colormaps, colorbars, scale bars, overlay masks),
+ `pcolormesh` (non-uniform 2-D meshes), `bar` (grouped, horizontal, log,
+ value labels), and 3-D `plot_surface` / `scatter3d` / `plot3d`.
+- **Layouts** — `subplots`, matplotlib-compatible `GridSpec` indexing
+ (slices, spans, negative indices), `width_ratios`/`height_ratios`,
+ `sharex`/`sharey` linked pan-zoom, and floating inset axes with
+ minimize/maximize.
+- **Markers** — static overlays (points, circles, ellipses, rectangles,
+ polygons, arrows, line segments, text, h/v lines) with matplotlib-style
+ kwargs and live `.set()` updates.
+- **Widgets** — draggable overlays (`RectangleWidget`, `CircleWidget`,
+ `AnnularWidget`, `CrosshairWidget`, `PolygonWidget`, `VLineWidget`,
+ `HLineWidget`, `RangeWidget`, …) that report positions back to Python.
+- **Events** — a two-tier callback system: `pointer_move` fires every drag
+ frame for cheap updates; `pointer_settled` / `pointer_up` fire once for
+ expensive recomputation. Plus `key_down`, `wheel`, `double_click`, and
+ per-line scoped handlers.
+- **Interactive docs** — the bundled `anyplotlib.sphinx_anywidget` extension
+ makes any anywidget figure live in Sphinx Gallery pages via Pyodide — no
+ kernel or server needed.
+- **Embeddable anywhere** — figures don't require Jupyter. Export
+ self-contained HTML (`fig.save_html("plot.html")`), mount the renderer
+ directly in an Electron app or web page via the JS `mount()` API, or run a
+ live Python backend over any transport with `anyplotlib.embed.FigureBridge`
+ (full callback support). See the embedding guide in the docs.
+
+```python
+import numpy as np
+import anyplotlib as apl
+
+fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(900, 400))
+img = ax_img.imshow(stack.mean(axis=2), cmap="viridis")
+spec = ax_spec.plot(stack[64, 64], units="eV")
+
+cross = img.add_widget("crosshair", cx=64, cy=64)
+
+@cross.add_event_handler("pointer_move") # every drag frame — keep it cheap
+def update(event):
+ spec.set_data(stack[int(cross.cy), int(cross.cx)])
+```
+
+## Installation
+
+```bash
+pip install anyplotlib
+```
+
+Works anywhere anywidget does: JupyterLab, Jupyter Notebook, VS Code,
+PyCharm, Google Colab, and marimo. Dependencies are intentionally light:
+`anywidget`, `numpy`, `traitlets`, and `colorcet` (no matplotlib required).
+
+## Documentation
+
+Full docs, a live example gallery (interactive in the browser — no install),
+and the event-system guide are at
+**[cssfrancis.github.io/anyplotlib](https://cssfrancis.github.io/anyplotlib/)**.
+
+## Development
+
+```bash
+git clone https://github.com/CSSFrancis/anyplotlib
+cd anyplotlib
+uv sync # install with dev dependencies
+uv run playwright install chromium # browsers for rendering tests
+uv run pytest # full suite (unit + Playwright + visual)
+make html # build the docs locally
+```
+
+The architecture is a single `anywidget.AnyWidget` (`Figure`) that owns all
+traitlets; plot objects are plain Python classes that serialise their state
+dicts to per-panel traits, and `figure_esm.js` renders them. See
+[AGENTS.md](AGENTS.md) for the codebase guide and
+[`anyplotlib/FIGURE_ESM.md`](anyplotlib/FIGURE_ESM.md) for a map of the JS
+renderer.
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/RELEASE_PLAN.md b/RELEASE_PLAN.md
new file mode 100644
index 00000000..efa49618
--- /dev/null
+++ b/RELEASE_PLAN.md
@@ -0,0 +1,104 @@
+# anyplotlib 0.1.0 — Release Plan
+
+Status as of 2026-06-12: `pyproject.toml` already says `0.1.0`, `CHANGELOG.rst`
+already contains a `v0.1.0 (2026-04-12)` section, but **no git tag exists and
+nothing is on PyPI** (the name `anyplotlib` is still available). The release
+automation (`prepare_release.yml` → tag → `release.yml` OIDC publish) is built
+and ready; what remains is mostly housekeeping.
+
+## Phase 1 — Clean the working tree (blockers)
+
+- [ ] **Decide on the uncommitted `anywidget_bridge.js` work** (+611 lines: a
+ HyperSpy/Enthought-traits shim for Pyodide). It is experimental and
+ unrelated to core plotting — either finish it on a feature branch or
+ stash it. Don't let it ride into the release commit unreviewed.
+- [ ] **Commit or drop `Examples/Interactive/plot_segment_by_contrast_advanced.py`**
+ (untracked). If kept, it runs in docs CI — verify it executes.
+- [ ] **Commit `uv.lock`** (currently untracked). CI uses `uv sync`; a
+ committed lockfile makes CI and contributor environments reproducible.
+- [ ] Commit the audit fixes from this session: `LICENSE`, packaging excludes,
+ classifier/keywords, colormap-fallback fix, `Plot3D` geometry refactor,
+ `vw` → `apl` alias standardization, README/AGENTS.md/FIGURE_ESM.md
+ updates.
+
+## Phase 2 — Reconcile the changelog and version
+
+The Prepare Release workflow can only bump *up* from 0.1.0, so for this first
+release do the changelog manually:
+
+- [ ] Fold the three pending `upcoming_changes/` fragments (6, 9, 11) into the
+ existing `v0.1.0` section of `CHANGELOG.rst` (or run
+ `uvx towncrier build --version 0.1.0` after deleting the stale section),
+ update the date, and delete the consumed fragments.
+- [ ] Verify `docs/conf.py` `release` string matches `0.1.0`.
+- [ ] Verify `docs/_root/switcher.json` has (or will get) a `v0.1.0` entry.
+
+## Phase 3 — One-time PyPI setup
+
+- [ ] On pypi.org, add a **pending trusted publisher**:
+ Owner `CSSFrancis`, repo `anyplotlib`, workflow `release.yml`,
+ environment `pypi` (matches the `environment:` block in release.yml).
+- [ ] Create the `pypi` environment in the GitHub repo settings (release.yml
+ references it; publishing fails without it).
+
+## Phase 4 — Pre-tag verification
+
+- [ ] CI green on `main` (tests.yml matrix: 3.10–3.13 × linux/mac/win, plus
+ lowest-direct resolution job).
+- [ ] `uv build`, then sanity-check the artifacts:
+ `uvx twine check dist/*` and install the wheel in a fresh venv,
+ `python -c "import anyplotlib"`. (After this session's packaging fix the
+ wheel no longer ships `anyplotlib/tests/` and PNG baselines — confirm
+ it is ~250 KB, not ~890 KB.)
+- [ ] Build docs locally (`make html`) and click through the interactive
+ gallery — the Pyodide bridge loads the wheel built from the release
+ commit.
+- [ ] Smoke-test in a real JupyterLab session: `subplots`, `imshow` + widget
+ drag, `plot` + vline widget, `bar`, `plot_surface`, inset.
+
+## Phase 5 — Ship
+
+```bash
+git fetch origin
+git tag v0.1.0 origin/main
+git push origin v0.1.0
+```
+
+This triggers `release.yml` (build → PyPI publish → GitHub Release with
+changelog notes) and the docs deploy. Afterwards:
+
+- [ ] Verify `pip install anyplotlib` works from a clean environment.
+- [ ] Verify the GitHub Release notes rendered correctly.
+- [ ] Check the versioned docs URL and the root redirect.
+
+## Post-0.1.0 backlog (quality items from the audit, none blocking)
+
+1. **Duplicate CI**: `ci.yml` and `tests.yml` both run pytest on every
+ push/PR (ubuntu + 3.12 overlaps). Move the Codecov upload into the
+ tests.yml ubuntu/3.12 job and delete `ci.yml`.
+2. **Colormap fidelity**: with colorcet installed (a hard dependency),
+ `"viridis"` silently renders as colorcet `bmy` and `"inferno"` as `kb`
+ (black→blue) — visually very different from the matplotlib maps users
+ expect. Consider embedding real 256-entry LUTs for the half-dozen most
+ common matplotlib names (a few KB) instead of aliasing.
+3. **Add a linter/formatter**: no ruff/flake8 config exists. Add `ruff`
+ (lint + format) to the dev group and CI; the codebase is clean enough
+ that adoption should be cheap.
+4. **Coverage in `addopts`**: `--cov` on every local `pytest` run slows quick
+ iterations and overwrites `coverage.xml`. Consider moving coverage flags
+ into the CI invocation only.
+5. **Typing**: annotations are partial (`_fig: object`, untyped dicts).
+ If type-checking is a goal, add `py.typed` + mypy/pyright gradually.
+6. **`Axes.imshow` silently drops RGB channels** (`data[:, :, 0]`). Either
+ render RGB properly or raise with a clear message; silent channel
+ dropping will surprise matplotlib users.
+7. **`figure_esm.js` size** (~4,400 lines, one closure): consider an
+ esbuild-based bundling step so the JS can live in modules while anywidget
+ still receives a single `_esm` string. Until then, keep
+ `FIGURE_ESM.md` regenerated (instructions are in its header).
+8. **`Event` dataclass breadth**: plot-type-specific fields (`bar_index`,
+ `ray`, `line_id`) live on the universal event. Fine at this scale; if
+ event types grow, consider per-kind payload dataclasses.
+9. **Large-scale 3-D rendering (WebGPU)**: scoped in `WEBGPU_PLAN.md` —
+ phased, demand-gated, canvas fallback contract. Phase 0 (canvas cheats +
+ `voxels_from_volume` resampling API) is worth shipping independently.
diff --git a/WEBGPU_PLAN.md b/WEBGPU_PLAN.md
new file mode 100644
index 00000000..44360d0b
--- /dev/null
+++ b/WEBGPU_PLAN.md
@@ -0,0 +1,234 @@
+# WebGPU-on-demand rendering — scoping document
+
+Status: **Phases 1–2 prototyped & hardware-verified** (2026-06-13).
+Instanced points (Phase 1) and voxels (Phase 2) render on the GPU with
+canvas fallback; projection + shaders validated on an NVIDIA Pascal GPU via
+offscreen-texture readback. Remaining: binary-trait transport for >200k
+payloads, the flagged CI smoke job, and Phase 3 (OIT translucency).
+Owner: @CSSFrancis
+Prerequisite reading: `anyplotlib/FIGURE_ESM.md` (3D drawing, voxels, plane widgets)
+
+## 1. Goal
+
+Render **large point clouds and voxel volumes** interactively — targets:
+
+| Workload | Today (Canvas2D) | Target (WebGPU) |
+|---|---|---|
+| `scatter3d` points | ~50k usable | **1M @ ≥30fps** |
+| `voxels` cubes | ~10k (≤30k after Phase 0) | **500k @ ≥30fps** |
+| Plane-drag re-slice | O(N) re-blit | **uniform update, 60fps at any N** |
+
+…without causing problems: every figure must keep working everywhere it works
+today (Jupyter, Pyodide docs, Electron embed, headless CI), with no new JS
+dependencies and no behaviour change for users below the GPU threshold.
+
+## 2. Non-goals
+
+- **Not** replacing Canvas2D — it remains the universal baseline, the
+ fallback, the small-N path, and the fully-CI-tested path, forever.
+- **No WebGL2** — we go straight to WebGPU; maintaining three paths is worse
+ than two. (Decided 2026-06: choosing in 2026, not 2023.)
+- **No three.js / no bundler** — raw WebGPU API, WGSL shaders as inline
+ strings in the single-file ESM.
+- **No 2D pipeline changes** — images/lines/bars stay Canvas2D.
+- **No WebGPU compute in early phases** (see Phase 4).
+
+## 3. Coverage & the fallback contract
+
+As of mid-2026: Chromium Win/Mac/Android and Electron ✓ (since 2023/24),
+Safari ≥26 ✓ (Sept 2025), Firefox Windows ✓ / macOS recent / Linux rolling
+out, Chrome Linux driver-dependent. Weak populations for *our* users: Linux
+workstations, remote-desktop/VM sessions (no adapter even in supporting
+browsers), older Safari. Estimated 15–25 % of scientific users today.
+
+**Contract:** WebGPU is a progressive enhancement. `navigator.gpu` present
+→ `requestAdapter()` resolves → device created → *then* a panel may switch.
+Any failure at any point (including mid-session device loss) lands on the
+Canvas2D path silently and permanently for that session. A figure must never
+render nothing because GPU was attempted.
+
+## 4. Architecture
+
+### 4.1 Activation policy
+
+- Python: `gpu="auto" | True | False` kwarg on `scatter3d()` / `voxels()`
+ → state field `gpu_mode`. Default `"auto"`.
+- JS (`auto`): attempt WebGPU only when `vertices_count > GPU_THRESHOLD`
+ (initial: 20 000 — at/below this Canvas2D is already smooth, so the
+ fallback population loses nothing). `True` forces an attempt at any count
+ (still falls back); `False` never attempts.
+
+### 4.2 Device lifecycle (the async-init problem)
+
+- One **module-level singleton** `_gpuDevicePromise` (adapter + device
+ requested once per page, on first demand).
+- Per-panel state `p._gpu ∈ {undefined, 'pending', 'active', 'unavailable'}`.
+- First frame is ALWAYS Canvas2D (render() stays synchronous). When the
+ device promise resolves, the panel builds its buffers/pipeline, flips to
+ `'active'`, and redraws; on rejection → `'unavailable'`.
+- `device.lost.then(...)`: mark every GPU panel `'unavailable'`, drop GPU
+ resources, redraw via Canvas2D. Never re-attempt within the session.
+
+### 4.3 Canvas split — decorations stay 2D
+
+Add one `gpuCanvas` to the 3D panel stack, *below* `plotCanvas`:
+
+```
+gpuCanvas (WebGPU) geometry only: instanced points / cubes
+plotCanvas (2D ctx) axes, ticks, labels (_drawTex), reference sphere,
+ plane-widget quads, highlight — unchanged code,
+ drawn on a now-transparent background
+overlayCanvas / markersCanvas / statusBar — unchanged
+```
+
+This is the key cost-control decision: **all decoration, label, TeX, sphere,
+plane-widget, and highlight code is reused verbatim**; only the instanced
+geometry moves to the GPU. The camera matrix is shared (same turntable
+`_rot3` semantics → one orthographic view-projection matrix uniform).
+
+### 4.4 Pipelines
+
+- **Points**: instanced screen-facing quads (point_size px), per-instance
+ position (f32×3) + colour (unorm8×4). Fragment discards outside the disc.
+- **Voxels**: one 36-vertex cube, instanced; per-instance position + colour.
+ Per-face shading via vertex normals (match the 0.82/0.68/1.0 canvas look).
+ Depth buffer → **no sorting at all**.
+- **Slice emphasis & planes as uniforms**: plane axis/position/count go into
+ a uniform buffer; the fragment shader computes emphasis
+ (`|pos[axis] − plane| ≤ size/2`). Plane drags therefore re-render with a
+ **uniform write only** — no geometry re-upload, no Python round-trip
+ needed for the visual.
+- Wire format already fits: `vertices_b64` (f32) and `point_colors_b64` (u8)
+ upload to GPUBuffers unchanged.
+
+### 4.5 Transparency strategy
+
+- Phase 1–2 GPU mode is **opaque** (depth-tested). For ≥100k elements this
+ reads *better* than alpha soup; it differs visually from the canvas
+ translucent look — documented, and `voxel_alpha` still applies on the
+ canvas path.
+- Phase 3 adds weighted-blended OIT (two extra render targets + composite
+ pass) to restore the translucent-volume aesthetic at scale. Gate: only
+ build if genuinely needed after using opaque mode in practice.
+
+### 4.6 Capability feedback → adaptive budgets (Python)
+
+JS reports the outcome once per panel via the existing state echo: a
+`_gpu_active: true|false` field written into the panel state (no new event
+type needed). Python exposes `plot.gpu_active`. The resampling helper
+(Phase 0) uses it: send full-resolution boundary voxels to GPU clients,
+auto-stride to ≤20k for canvas clients. **No client ever receives a payload
+it can't render.**
+
+### 4.7 Payload reality check (often the real bottleneck)
+
+1M points = 12 MB f32 → ~16 MB as b64-in-JSON through the comm. Phase 2
+includes moving large geometry to **binary traits** (ipywidgets/anywidget
+support binary buffers; `_repr_utils._widget_state` already handles `bytes`)
+with b64 kept for small payloads and the standalone/Pyodide paths. Without
+this, the wire — not the GPU — caps practical sizes around ~200k points.
+
+## 5. Phases
+
+### Phase 0 — Canvas cheats + resampling API (no GPU code; do first)
+*~2–3 days. Worth shipping regardless of WebGPU.*
+
+1. Interaction LOD: stride the draw set 2–4× while a drag is active; full
+ set on release/settle.
+2. Analytic back-to-front order for grid voxels (camera octant → lexicographic
+ traversal; kills the O(n log n) sort).
+3. Layered plane-drag cache: bake the translucent base cloud to a bitmap;
+ redraw only the emphasized slice voxels per drag frame.
+4. `Axes.voxels_from_volume(vol, *, max_voxels=15000, mode="boundary"|"stride",
+ colors=...)` — formalises the explorer example's hand-rolled extraction.
+
+**Acceptance:** 25–30k voxels orbit smoothly on canvas (bench: orbit ≤35 ms
+software); plane drag ≤10 ms at 20k; new benchmarks committed.
+
+### Phase 1 — GPU infrastructure + instanced points
+*~4–5 days. The risk-retiring phase.*
+
+Device singleton, `gpuCanvas` stack integration, async swap, device-lost
+fallback, `gpu_mode`/`_gpu_active` plumbing, instanced point pipeline.
+
+**Acceptance:**
+- 1M points orbit ≥30fps on a real GPU (manual + flagged CI job).
+- Kill switch verified: adapter-absent, mid-session device loss, and
+ `gpu=False` all render identically to today via canvas (automated).
+- Embedding `mount()` and the Pyodide docs page work in GPU mode
+ (verify WebGPU inside the gallery iframes — srcdoc/permission policy).
+
+### Phase 2 — Instanced voxels + shader slice emphasis + binary traits
+*~3–4 days.*
+
+Cube pipeline, plane uniforms (emphasis in-shader), plane-drag = uniform
+update, binary-trait transport for large buffers.
+
+**Acceptance:** 500k cubes orbit ≥30fps; plane drag 60fps at 500k; voxel
+grain explorer runs a 192³-extracted volume (~150k boundary voxels) live.
+
+### Phase 3 — Translucency (weighted-blended OIT) *(gated)*
+*~4–6 days. Only if opaque mode proves insufficient in real use.*
+
+**Acceptance:** GPU translucent render within visual tolerance of the canvas
+look at N ≤ 4k (screenshot comparison), correct at 500k.
+
+### Phase 4 — Future options *(not scoped)*
+GPU compute culling/LOD, surfaces/lines on GPU, picking via ID buffer.
+
+## 6. Testing & CI strategy
+
+- **Canvas path keeps 100 % of today's coverage** and remains the default CI
+ matrix — GPU never reduces existing test fidelity.
+- New **flagged headless GPU smoke job** (ubuntu): Chromium with
+ `--enable-unsafe-webgpu --enable-features=Vulkan` on lavapipe/SwiftShader-
+ Vulkan; tests `pytest.skip` cleanly when `requestAdapter()` yields null so
+ the job can never hard-fail on runner GPU availability.
+- Fallback tests run in the NORMAL suite (no flags): assert `_gpu_active`
+ is false and rendering matches canvas baselines when GPU is absent —
+ this is the path that protects "no problems".
+- Benchmarks: `js_gpu_points_1M`, `js_gpu_voxels_500k` added to the existing
+ hardware-gated baseline framework (recorded on a real-GPU machine).
+- Phase 3 parity: SSIM-style screenshot comparison GPU vs canvas at small N.
+
+## 7. Risks
+
+| Risk | Severity | Mitigation |
+|---|---|---|
+| Async init race / blank first paint | High | First frame always canvas; swap on resolve; `'pending'` state |
+| CI has no GPU adapter | High | Skip-on-unavailable smoke job; canvas keeps full coverage |
+| Device lost mid-session | Med | Permanent per-session fallback; tested by forcing `device.destroy()` |
+| Comm payload size (≥200k pts) | High | Phase 2 binary traits; capability-aware resampling caps payloads |
+| Opaque-vs-translucent visual surprise | Med | Document; Phase 3 OIT; `gpu=False` escape hatch |
+| WebGPU inside docs iframes (permission policy) | Med | Verify in Phase 1 acceptance; fall back if blocked |
+| Safari/WGSL implementation quirks | Low-Med | Stick to core WGSL, no extensions; manual Safari pass per phase |
+| Two render paths drift apart | Med | Shared camera/constants; parity screenshots; FIGURE_ESM.md section per path |
+
+## 8. Decision gates
+
+- **Gate A (after Phase 0):** if resampled canvas + linked slices satisfies
+ the 512×512×300 workflow in practice, pause here — GPU work is demand-
+ driven, not speculative.
+- **Gate B (after Phase 1):** confirmed-working fallback matrix + real-GPU
+ point benchmark before any voxel pipeline work.
+- **Gate C (before Phase 3):** a concrete use case that opaque mode cannot
+ serve.
+
+## 9. API sketch
+
+```python
+# Python
+plot = ax.voxels_from_volume(gid_volume, max_voxels=15_000,
+ mode="boundary", colors=grain_rgb) # Phase 0
+plot = ax.voxels(x, y, z, colors=c, gpu="auto") # Phase 2
+plot.gpu_active # bool, after first render echo
+plot = ax.scatter3d(x, y, z, colors=c, gpu=True) # Phase 1
+```
+
+```js
+// JS internals (figure_esm.js)
+_gpuDevice() // module singleton → Promise
+p._gpu // 'pending' | 'active' | 'unavailable'
+_buildPointPipeline(device, p) / _buildVoxelPipeline(device, p)
+_drawGpu3d(p) // geometry; decorations still drawn by draw3d's 2D code
+```
diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md
new file mode 100644
index 00000000..3eec2802
--- /dev/null
+++ b/anyplotlib/FIGURE_ESM.md
@@ -0,0 +1,303 @@
+# FIGURE_ESM.md — Navigator for `figure_esm.js`
+
+`figure_esm.js` is **~4,640 lines** and one big closure. Everything lives inside
+`function render({ model, el })` so that all helpers share the same scope
+(`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you
+can jump straight to the relevant code without reading the whole file.
+
+> **Keeping this file fresh:** line numbers drift as the JS evolves. The
+> section banners are greppable — regenerate the quick-reference with
+> `rg -n '^\s*// ──' anyplotlib/figure_esm.js` and function anchors with
+> `rg -n '^\s*function \w+' anyplotlib/figure_esm.js`. Update this file
+> whenever a PR moves a section by more than ~50 lines.
+
+---
+
+## Sizing contract
+
+```
+Rule 1 – Grid tracks are always pure ratio math.
+ col_px[i] = fig_width × width_ratios[i] / Σ width_ratios
+ row_px[r] = fig_height × height_ratios[r] / Σ height_ratios
+ No exceptions. No 2-D special-casing. Both Python
+ (_compute_cell_sizes) and JS (_applyFigResizeDOM) follow this rule.
+
+Rule 2 – All panels in the same grid column have the same canvas width.
+ All panels in the same grid row have the same canvas height.
+ (Follows automatically from Rule 1.)
+
+Rule 3 – Images are displayed "contain" (letterbox / pillarbox).
+ _imgFitRect(iw, ih, cw, ch) → largest rect of aspect iw:ih
+ that fits inside cw×ch, centred.
+
+Rule 4 – Zoom is relative to the fit-rect.
+ zoom=1 → fit-rect exactly filled by the whole image.
+ zoom=Z → a 1/Z portion of the image fills the fit-rect.
+
+Rule 5 – Text never clips. Optional gutters earn real layout space:
+ the colorbar (strip + label, _cbWidth) is subtracted from the
+ image width; the 2D title strip (_padT) grows for large or TeX
+ titles; 1D/bar titles clamp their drawn size to the fixed strip
+ (_titlePx); edge tick labels are nudged inward.
+```
+
+---
+
+## Quick-reference: function anchors
+
+| Section / function | Line |
+|--------------------|------|
+| Shared plot-area padding (`PAD_*`) | 9 |
+| Theme (dark/light detection) | 15 |
+| Shared math helpers | 53 |
+| b64 array decode helpers | 95 |
+| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 147 / 214 / 236 |
+| **2D gutter geometry**: `_cbWidth` / `_padT` / `_titlePx` | 287 / 299 / 309 |
+| **Layout engine** `applyLayout` | 590 |
+| `_buildCanvasStack` | 656 |
+| `_createPanelDOM` | 763 |
+| `_createInsetDOM` / `_applyAllInsetStates` | 846 / 968 |
+| `_resizePanelDOM` | 1027 |
+| **2D drawing**: `_imgFitRect` | 1176 |
+| `draw2d` | 1258 |
+| `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 |
+| `_drawAxes2d` (ticks, labels, title) | 1491 |
+| `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 |
+| **3D drawing**: `draw3d` | 1833 |
+| Event emission `_emitEvent` | 2031 |
+| 3D event handlers `_attachEvents3d` | 2059 |
+| **1D drawing**: `draw1d` | 2177 |
+| `drawOverlay1d` / `drawMarkers1d` | 2516 / 2586 |
+| Marker hit-test `_markerHitTest2d` | 2787 |
+| Panel event dispatch `_attachPanelEvents` | 2905 |
+| 2D events `_attachEvents2d` | 2928 |
+| 1D events `_attachEvents1d` | 3201 |
+| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 3409 / 3491 |
+| 1D widget drag `_canvasXToFrac1d` … | 3565 |
+| Shared-axis propagation `_getShareGroups` | 3650 |
+| Figure resize `_applyFigResizeDOM` | 3714 |
+| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 3902 / 3965 / 4341 |
+| Generic redraw `_redrawPanel` | 4531 |
+
+---
+
+## Rich-text (mini-TeX) label engine
+
+Canvas cannot run MathJax, so labels support a small TeX subset inside
+`$...$` delimiters — superscripts/subscripts (`$10^{-3}$`, `$E_F$`), Greek
+letters (`\alpha`…`\Omega`), and symbols (`\times`, `\AA`, `\degree`,
+`\propto`, …; see `_TEX_SYM`). `\mathrm{...}` gives upright text; math-mode
+letters are italic. Python stores label strings verbatim — all parsing
+happens here at draw time.
+
+| Function | Purpose |
+|----------|---------|
+| `_texRuns(text)` | Parse a label into runs `[{t, lvl, it}]` — lvl 0/+1/−1, it = italic |
+| `_texLayout(ctx, text, px, weight, family)` | Measure runs; sup/sub at 0.68×, dy −0.28/+0.16 em from a shared alphabetic baseline |
+| `_drawTex(ctx, text, x, y, px, opts)` | Draw a label. `opts: {align, weight, family}`. Fast path (no `$`) is a single `fillText`. Respects caller's `fillStyle`/`textBaseline`. |
+
+**Baseline conversion gotcha:** `TextMetrics.fontBoundingBoxAscent` is
+measured **relative to the current `textBaseline`**, not alphabetic.
+`_drawTex` therefore measures the ascent under the caller's baseline AND
+under `alphabetic`, and shifts by the difference — this makes TeX text land
+at exactly the same height a plain `fillText` would.
+
+**All axis labels, titles, the colorbar label, 3D axis labels, and log tick
+labels (`$10^{N}$`) render through `_drawTex`.** Font sizes come from state
+with fallbacks to the historical defaults: `title_size||11`,
+`x_label_size`/`y_label_size` (11 for 2D, 9 for 1D units, 10 for bar, 11 for
+3D), `tick_size||10`, `colorbar_label_size||10`.
+
+## 2D gutter geometry helpers
+
+| Function | Purpose |
+|----------|---------|
+| `_cbWidth(st)` | Width reserved for the colorbar: 0 when hidden, else `16 + (label ? label_size+8 : 0)`. Subtracted from the image width in `_resizePanelDOM` / `_resizePanelCSS` so the strip + label always fit inside the panel. |
+| `_padT(st)` | 2D title-strip height: `PAD_T` (12) for default-size plain titles (pixel-identical layouts); grows to `ceil(size*1.3)+2..4` for `title_size > 11` or TeX titles (superscript rise). Stored as `p._padT`. |
+| `_titlePx(st)` | Drawn title size for fixed-strip panels (1D/bar): clamps to 11 (10 for TeX titles) so nothing clips. |
+
+`draw2d` calls `_resizePanelDOM` on every state push, so colorbar/title
+geometry changes (visibility, label, sizes) re-layout automatically.
+
+---
+
+## Layout / panel details
+
+#### `applyLayout()` (line 590)
+Reads `layout_json`. Builds CSS grid tracks from `panel_specs[].panel_width/height`.
+Creates panels that don't exist yet, resizes existing ones, removes stale ones.
+Also creates/updates inset panels from `inset_specs`.
+
+#### `_createPanelDOM(id, kind, pw, ph, spec)` (line 763)
+Builds all canvas/DOM elements for one panel (via `_buildCanvasStack`),
+stores the **`p` object** in `panels`, subscribes to
+`change:panel_{id}_json`, runs the initial draw.
+
+**DOM structure by kind:**
+| kind | elements |
+|------|----------|
+| `'2d'` | `plotWrap > plotCanvas + overlayCanvas + markersCanvas + yAxisCanvas + xAxisCanvas + cbCanvas + scaleBar + statusBar + titleCanvas` |
+| `'3d'` | `wrap3 > plotCanvas + overlayCanvas + markersCanvas + statusBar` |
+| `'1d'` / `'bar'` | `wrap > plotCanvas + overlayCanvas + markersCanvas + statusBar` |
+
+#### `_resizePanelDOM(id, pw, ph)` (line 1027)
+Updates `canvas.width / canvas.height` (DPR-scaled) for every canvas in the
+panel. For 2D, computes `imgX/imgY/imgW/imgH` from the gutters
+(`PAD_*`, `_padT`, `_cbWidth`) and stores them on `p` plus `p._cbW`/`p._padT`.
+
+#### The `p` (panel) object — key fields
+```js
+p.id, p.kind, p.pw, p.ph
+p.state // parsed JSON from panel_{id}_json (full plot state dict)
+p.imgX, p.imgY, p.imgW, p.imgH // 2D inner image area (gutters removed)
+p._cbW, p._padT // 2D gutter geometry at last layout
+p.plotCanvas/.overlayCanvas/.markersCanvas (+ 2D: x/yAxisCanvas, cbCanvas,
+p.titleCanvas, p.scaleBar), p.statusBar
+p.blitCache // { bitmap, bytesKey, lutKey, w, h } — ImageBitmap cache
+p.ovDrag / p.ovDrag2d / p.isPanning
+```
+
+---
+
+## 2D drawing (from line 1176)
+
+Key state fields:
+```
+st.image_b64, st.image_width/height
+st.zoom, st.center_x/y
+st.display_min/max, st.raw_min/max, st.scale_mode
+st.colormap_data [[r,g,b], ...] × 256
+st.x_axis, st.y_axis, st.axis_visible
+st.markers, st.overlay_widgets, st.overlay_mask_b64/_color/_alpha
+st.title_size, st.x_label_size, st.y_label_size, st.tick_size,
+st.colorbar_label_size (label font sizes; optional)
+```
+
+| Function | Line | Purpose |
+|----------|------|---------|
+| **`_imgFitRect(iw,ih,cw,ch)`** | **1176** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this |
+| `draw2d(p)` | 1258 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
+| `drawScaleBar2d(p)` | 1360 | Physical scale bar |
+| `drawColorbar2d(p)` | 1436 | Gradient strip + min/max marks + rotated label centred in the `_cbWidth` gutter |
+| `_drawAxes2d(p)` | 1491 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
+| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 1629 / 1685 | Widgets / marker groups |
+
+Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a
+`1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact
+inverses of the blit geometry.
+
+---
+
+## 3D drawing (line ~1840)
+Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts
+triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`).
+
+- **Camera** (`_rot3`): turntable with matplotlib azim/elev semantics —
+ azimuth spins about the DATA z-axis, elevation tilts toward the viewer.
+ Faces unit vector v when `el = asin(vz)`, `az = atan2(vx, -vy)`.
+- **Scatter colours**: `st.point_colors_b64` (uint8 RGB triplets) gives
+ per-point colours; empty string falls back to `st.color`.
+- **Highlight**: `st.highlight = {x,y,z,color,size}` draws an emphasised
+ ringed dot on top of everything (semi-transparent on the far side).
+- **Reference sphere**: `st.sphere = {radius,color,alpha,wireframe}` draws a
+ shaded silhouette disk + lat/long wireframe behind the geometry; far-side
+ wireframe segments and scatter points are dimmed.
+- **Voxels** (`geom_type 'voxels'`): shaded translucent cubes at the vertex
+ centres. `st.voxel_size`, `st.voxel_alpha`, `st.voxel_slice_alpha`.
+ Performance design (budget ~3–6 µs/cube, ≤ ~20k cubes interactive):
+ cube-corner screen offsets + face visibility computed once per frame;
+ per-(colour, emphasis) sprites blitted with integer-snapped `drawImage`
+ (≤256 unique colours; falls back to path fills above); typed-array
+ projection + depth-sort cached per (geometry generation, view, panel
+ size) so camera-static redraws (plane drags) only re-blit. Benchmarks:
+ `test_bench_voxels_orbit` / `test_bench_voxels_reblit`.
+- **Echo guard**: `_attachEvents3d` writes interaction state via
+ `_writeState()` (sets `p._selfWrite`), and the panel-json listener skips
+ self-writes — without this every drag frame paid a second
+ JSON.parse + full redraw.
+- **Touch bridge** (`_attachTouch`, called from `_attachPanelEvents` for
+ every panel kind): translates touch gestures into the *existing* mouse /
+ wheel handlers via real `MouseEvent` / `WheelEvent` dispatch — 1-finger →
+ mousedown/move/up, 2-finger pinch → wheel (anchored at the gesture
+ midpoint via `p.mouseX/Y`), double-tap → dblclick. `move`/`up` go to
+ `document` (handlers listen there for off-canvas drags); `down`/`wheel`/
+ `dblclick` go to the overlay canvas. Overlay canvases set
+ `touch-action:none` so the browser yields gestures to the plot. No
+ handler rewrites — a working mouse interaction is automatically a working
+ touch one.
+- **Geometry channel** (perf): plots that declare `_GEOM_KEYS` on the Python
+ side (Plot2D, Plot3D) split heavy keys (`vertices_b64`, `image_b64`,
+ `colormap_data`, …) into a second `panel__geom` trait, re-sent only
+ when their content hash changes; the view trait carries `_geom_rev`. JS
+ caches the decoded geom (`p._geomCache`/`p._geomRev`) and `_applyGeom`
+ splices it into the state before every draw, so view-only updates
+ (highlight, camera, planes, title) never re-parse or re-transmit
+ geometry. Both the `change:panel__geom` and `change:panel__json`
+ listeners call `_applyGeom`; the geom trait is loaded before the first
+ draw. Pairs with `Figure.batch()` push-coalescing on the Python side.
+- **WebGPU path** (progressive enhancement, additive): scatter points
+ (`_GPU_POINT_WGSL`) and voxels (`_GPU_VOXEL_WGSL`) render instanced on the
+ GPU when available and above threshold (`GPU_POINT_THRESHOLD` 20k /
+ `GPU_VOXEL_THRESHOLD` 8k); `gpu_mode` ∈ auto/always/off. `gpuCanvas` sits
+ below `plotCanvas`; decorations always draw on the 2D `plotCanvas` over a
+ transparent background. `_gpuMatrix` reproduces the canvas projection
+ EXACTLY (verify numerically — the y-coefficients are NOT negated: canvas
+ screen-y-down and NDC-y-up cancel). Voxel slice emphasis + per-face shade
+ are uniforms, so plane drags are a uniform write. Every failure path
+ (no `navigator.gpu`, null adapter, device lost, draw throw) sets
+ `p._gpu='unavailable'` and the Canvas2D path renders unchanged. **Testing:
+ use offscreen-texture readback (`copyTextureToBuffer`), NOT screenshots —
+ the WebGPU swapchain doesn't snapshot reliably under automation.**
+- **Plane widgets** (`st.overlay_widgets`, type `'plane'`): translucent
+ draggable slice selectors. `draw3d` caches screen quads + the axis screen
+ direction on `p._3dPlanes`; `_attachEvents3d` hit-tests them on mousedown
+ (plane drag wins over orbit) and drags along the normal. Voxels within
+ half a voxel of a plane render at `voxel_slice_alpha`. NOTE: during drags
+ re-resolve widgets by id in `p.state` — object references go stale because
+ the model echo replaces `p.state` on every `save_changes()`.
+- `st.data_bounds` may be fixed from Python (`bounds=` kwarg) so geometry
+ normalisation stays origin-true (unit-sphere direction vectors).
+
+## Events
+- `_emitEvent(panelId, eventType, widgetId, extraData)` (line 2031) writes
+ `{source:'js', ...}` to `model.event_json`; `eventType` is any
+ `pointer_*` / `key_*` / `wheel` / `double_click` string
+ (see `callbacks.VALID_EVENT_TYPES`).
+- Kind-specific attach functions: 3D 2059, 2D 2928, 1D 3201, bar 4341.
+- Widget drag: 2D hit-test/drag 3409/3491; 1D from 3565.
+
+## 1D drawing (line 2177)
+`draw1d` renders series (b64 decode cache), axes, ticks (log ticks as TeX
+`$10^{N}$`; edge labels nudged inward), grid, legend, units labels + title
+via `_drawTex` (title size clamped via `_titlePx`).
+
+## Bar chart (lines 3902–4530)
+`_barGeom` (3902) computes per-bar geometry incl. grouped offsets and
+log-scale mappers; `drawBar` (3965) renders grid, bars, value labels, ticks
+(log ticks as TeX superscripts, category edge labels nudged inward), legend,
+labels + clamped title; `_attachEventsBar` (4341) handles drag/hover/click.
+Bar zoom/pan modifies `st.data_min/max` (value axis); `view_x0/x1` stays 0/1.
+
+---
+
+## Key data flows
+
+```
+Python push:
+ plot._push() → figure._push(id) → panel_{id}_json trait changes
+ → model.on('change:panel_{id}_json') → p.state = JSON.parse(...)
+ → _redrawPanel(p)
+
+JS → Python (widget drag):
+ _doDrag2d / _doDrag1d → updates p.state.overlay_widgets in-place
+ → _emitEvent(id, 'pointer_move', widgetId, {…})
+ → model.set('event_json', …) + save_changes()
+ → Python Figure._on_event() → Widget._update_from_js() + CallbackRegistry.fire()
+
+JS → Python (3D rotate / zoom):
+ _attachEvents3d → model.set('panel_{id}_json', …) + save_changes()
+
+Python → JS (set widget position from Python):
+ widget.set(…) → Figure._push_widget → event_json with source:'python'
+ → model.on('change:event_json') patches overlay_widgets + redraws
+```
diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py
index f0985027..2671c4df 100644
--- a/anyplotlib/__init__.py
+++ b/anyplotlib/__init__.py
@@ -1,4 +1,45 @@
from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots
-from anyplotlib.figure_plots import PlotMesh, Plot3D
+from anyplotlib.axes import Axes, InsetAxes
+from anyplotlib.plot1d import Plot1D, PlotBar
+from anyplotlib.plot1d._plot1d import Line1D
+from anyplotlib.plot2d import Plot2D, PlotMesh
+from anyplotlib.plot3d import Plot3D
+from anyplotlib.plotxy import PlotXY
+from anyplotlib.callbacks import CallbackRegistry, Event
+from anyplotlib import embed
+from anyplotlib.markers import MarkerRegistry, MarkerGroup
+from anyplotlib.widgets import (
+ Widget, RectangleWidget, CircleWidget, AnnularWidget,
+ CrosshairWidget, PolygonWidget, LabelWidget,
+ VLineWidget, HLineWidget, RangeWidget, PlaneWidget,
+)
-__all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots", "PlotMesh", "Plot3D"]
+# ── Global help flag ──────────────────────────────────────────────────────
+# Set to False to suppress help badges on all figures in this session.
+# Default True: badges appear whenever a figure has help text set.
+show_help: bool = True
+
+_COLOR_CYCLE: list[str] = [
+ "#4fc3f7", "#ff7043", "#aed581", "#ffd54f",
+ "#ba68c8", "#4db6ac", "#f06292", "#90a4ae",
+ "#ffb74d", "#a5d6a7",
+]
+
+
+def get_color_cycle() -> list[str]:
+ """Return the default color cycle as a list of CSS hex strings."""
+ return list(_COLOR_CYCLE)
+
+
+__all__ = [
+ "Figure", "GridSpec", "SubplotSpec", "subplots",
+ "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar",
+ "Line1D",
+ "CallbackRegistry", "Event",
+ "MarkerRegistry", "MarkerGroup",
+ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
+ "CrosshairWidget", "PolygonWidget", "LabelWidget",
+ "VLineWidget", "HLineWidget", "RangeWidget", "PlaneWidget",
+ "show_help", "get_color_cycle",
+ "embed",
+]
diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py
new file mode 100644
index 00000000..0dbe7be9
--- /dev/null
+++ b/anyplotlib/_base_plot.py
@@ -0,0 +1,229 @@
+"""
+_base_plot.py
+=============
+Shared base classes and mixins for all plot panel types.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+
+from anyplotlib.callbacks import _EventMixin
+
+
+class _BasePlot(_EventMixin):
+ """Universal base for Plot1D, Plot2D, PlotBar, and Plot3D.
+
+ Contains methods identical across all four panel types and helper
+ utilities used by view-setter and widget-adder methods.
+
+ Subclasses must define:
+ _state : dict — the panel state dict
+ _push() -> None — serialize state and write to parent Figure
+ """
+
+ def configure_pointer_settled(self, ms: int, delta: float = 4) -> None:
+ """Configure the pointer-settled event threshold (ms and pixel delta)."""
+ self._state["pointer_settled_ms"] = ms
+ self._state["pointer_settled_delta"] = delta
+ self._push()
+
+ _configure_pointer_settled = configure_pointer_settled
+
+ #: Mini-TeX formatting note shared by all label setters.
+ #:
+ #: Label strings support a small TeX subset inside ``$...$`` delimiters,
+ #: rendered by the JS canvas engine (no MathJax needed):
+ #:
+ #: * ``$10^{-3}$`` / ``$x^2$`` — superscripts (exponents)
+ #: * ``$E_F$`` / ``$k_{B}T$`` — subscripts
+ #: * ``$\\alpha$ … $\\Omega$`` — Greek letters
+ #: * ``\\times \\cdot \\pm \\degree \\AA \\infty \\propto \\approx``
+ #: ``\\leq \\geq \\neq \\partial \\nabla \\hbar \\rightarrow`` — symbols
+ #: * ``$\\mathrm{...}$`` — upright text inside math (letters in
+ #: math mode are italic by default)
+ #:
+ #: Example: ``plot.set_xlabel(r"$q$ ($\\AA^{-1}$)", fontsize=14)``
+
+ def _set_label(self, key: str, label: str, size_key: str,
+ fontsize: float | None) -> None:
+ """Store a label string (TeX subset allowed) and its optional size."""
+ self._state[key] = str(label)
+ if fontsize is not None:
+ self._state[size_key] = float(fontsize)
+ self._push()
+
+ def set_title(self, label: str, fontsize: float | None = None) -> None:
+ """Set the panel title.
+
+ Parameters
+ ----------
+ label : str
+ Title text. Supports the mini-TeX subset (``$10^{-3}$``,
+ ``$\\alpha$``, …) — see the class notes on label formatting.
+ fontsize : float, optional
+ Font size in CSS pixels. Default 11. On 2-D panels the title
+ strip grows to fit larger sizes. 1-D and bar titles render in a
+ fixed 12-px strip, so the drawn size is clamped to 11 there.
+ """
+ self._set_label("title", label, "title_size", fontsize)
+
+ def set_axis_off(self) -> None:
+ self._state["axis_visible"] = False
+ self._push()
+
+ def set_axis_on(self) -> None:
+ self._state["axis_visible"] = True
+ self._push()
+
+ @contextmanager
+ def _python_view_push(self):
+ """Context manager for view setters that must signal _view_from_python.
+
+ Sets the flag on entry, yields for state mutations, then pushes
+ and clears the flag on exit.
+ """
+ self._state["_view_from_python"] = True
+ try:
+ yield
+ finally:
+ self._push()
+ self._state["_view_from_python"] = False
+
+ def _make_widget_push_fn(self, widget):
+ """Return a targeted-push closure for a widget.
+
+ Replaces the repeated _tp / _targeted_push closures in every
+ add_*_widget method.
+ """
+ plot_ref, wid_id = self, widget._id
+ def _push():
+ if plot_ref._fig is not None:
+ fields = {k: v for k, v in widget._data.items()
+ if k not in ("id", "type")}
+ plot_ref._fig._push_widget(plot_ref._id, wid_id, fields)
+ return _push
+
+
+class _PanelMixin:
+ """Mixin for panels that support interactive widgets and tick control.
+
+ Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget
+ serialization), widget management, and tick visibility control.
+
+ Subclasses must define:
+ _state : dict
+ _fig : object
+ _id : str
+ _widgets : dict[str, Widget]
+ """
+
+ def _push(self) -> None:
+ if self._fig is None:
+ return
+ self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
+ self._fig._push(self._id)
+
+ def set_tick_label_size(self, size: float) -> None:
+ """Set the font size of the tick (axis number) labels in CSS pixels.
+
+ Applies to both axes of the panel. Default 10.
+
+ Parameters
+ ----------
+ size : float
+ Tick label font size in pixels.
+ """
+ self._state["tick_size"] = float(size)
+ self._push()
+
+ def set_ticks_visible(self, visible: bool, *, x: bool | None = None,
+ y: bool | None = None) -> None:
+ if x is None and y is None:
+ self._state["x_ticks_visible"] = bool(visible)
+ self._state["y_ticks_visible"] = bool(visible)
+ else:
+ if x is not None:
+ self._state["x_ticks_visible"] = bool(x)
+ if y is not None:
+ self._state["y_ticks_visible"] = bool(y)
+ self._push()
+
+ def get_widget(self, wid):
+ """Return the Widget object by ID string or Widget instance."""
+ from anyplotlib.widgets import Widget
+ if isinstance(wid, Widget):
+ wid = wid.id
+ try:
+ return self._widgets[wid]
+ except KeyError:
+ raise KeyError(wid)
+
+ def remove_widget(self, wid) -> None:
+ """Remove a widget by ID string or Widget instance."""
+ from anyplotlib.widgets import Widget
+ if isinstance(wid, Widget):
+ wid = wid.id
+ if wid not in self._widgets:
+ raise KeyError(wid)
+ del self._widgets[wid]
+ self._push()
+
+ def list_widgets(self) -> list:
+ """Return a list of all active widget objects on this panel."""
+ return list(self._widgets.values())
+
+ def clear_widgets(self) -> None:
+ """Remove all interactive overlay widgets from this panel."""
+ self._widgets.clear()
+ self._push()
+
+
+class _MarkerMixin:
+ """Mixin for panels that support static marker collections.
+
+ Shared by Plot1D and Plot2D.
+
+ Subclasses must define:
+ _state : dict
+ markers : MarkerRegistry
+ _push() -> None
+ """
+
+ def _push_markers(self) -> None:
+ self._state["markers"] = self.markers.to_wire_list()
+ self._push()
+
+ def _add_marker(self, mtype: str, name, **kwargs):
+ return self.markers.add(mtype, name, **kwargs)
+
+ def remove_marker(self, marker_type: str, name: str) -> None:
+ """Remove a named marker collection by type and name.
+
+ Parameters
+ ----------
+ marker_type : str
+ Collection type, e.g. ``"points"``, ``"vlines"``.
+ name : str
+ The name used when the collection was created.
+ """
+ self.markers.remove(marker_type, name)
+
+ def clear_markers(self) -> None:
+ """Remove all marker collections from this panel."""
+ self.markers.clear()
+
+ def list_markers(self) -> list:
+ """Return a summary list of all marker collections on this panel.
+
+ Returns
+ -------
+ list of dict
+ Each dict has keys ``"type"``, ``"name"``, and ``"n"``
+ (number of markers in the collection).
+ """
+ out = []
+ for mtype, td in self.markers._types.items():
+ for name, g in td.items():
+ out.append({"type": mtype, "name": name, "n": g._count()})
+ return out
diff --git a/anyplotlib/_electron.py b/anyplotlib/_electron.py
new file mode 100644
index 00000000..fbbd5c1f
--- /dev/null
+++ b/anyplotlib/_electron.py
@@ -0,0 +1,74 @@
+"""
+_electron.py
+============
+Electron app bridge for anyplotlib figures.
+
+Registers figures so their trait changes are forwarded to the Electron
+renderer via stdout, and provides dispatch_event() so the renderer can
+send interaction events back to Python.
+"""
+from __future__ import annotations
+
+import json
+import sys
+import uuid
+
+_figures: dict[str, object] = {} # fig_id -> Figure
+
+
+def register(fig) -> str:
+ """Register *fig* for bidirectional state sync and return its fig_id."""
+ fig_id = uuid.uuid4().hex[:8]
+ _figures[fig_id] = fig
+
+ def _on_change(change):
+ name = change["name"]
+ value = change["new"]
+ if isinstance(value, (bytes, bytearray)):
+ import base64
+ value = {"buffer": base64.b64encode(value).decode()}
+ emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
+
+ for name in fig.traits(sync=True):
+ if not name.startswith("_"):
+ try:
+ fig.observe(_on_change, names=[name])
+ except Exception:
+ pass
+
+ return fig_id
+
+
+def resize_figure(fig_id: str, width: int, height: int) -> None:
+ """Update fig_width / fig_height and push new layout to the iframe."""
+ fig = _figures.get(fig_id)
+ if fig is None:
+ return
+ try:
+ # Batch both trait changes so _on_resize fires only once each.
+ with fig.hold_trait_notifications():
+ fig.fig_width = int(width)
+ fig.fig_height = int(height)
+ except Exception:
+ pass
+
+
+def dispatch_event(fig_id: str, event_json: str) -> None:
+ """Apply a frontend interaction event to the registered figure."""
+ fig = _figures.get(fig_id)
+ if fig is None:
+ return
+ try:
+ # Figure.show() registers Figure objects which use _dispatch_event(raw_json_str).
+ # Standalone widgets use _update_from_js(dict, event_type).
+ if hasattr(fig, "_dispatch_event"):
+ fig._dispatch_event(event_json)
+ elif hasattr(fig, "_update_from_js"):
+ fig._update_from_js(json.loads(event_json))
+ except Exception:
+ pass
+
+
+def emit(obj: dict) -> None:
+ sys.stdout.write(f"PLOTAPP:{json.dumps(obj, default=str)}\n")
+ sys.stdout.flush()
diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py
index 42be9d61..f8e57622 100644
--- a/anyplotlib/_repr_utils.py
+++ b/anyplotlib/_repr_utils.py
@@ -7,7 +7,7 @@
Strategy
--------
- and 1. Serialise every synced traitlet value to a plain JSON dict.
+1. Serialise every synced traitlet value to a plain JSON dict.
2. Embed that dict and the widget's ``_esm`` source directly in the page.
3. Provide a minimal model shim (get/set/on/save_changes) so the ESM's
render() function works without any Jupyter comm infrastructure.
@@ -24,6 +24,11 @@
from html import escape
from uuid import uuid4
+# Maximum display width (px) for the non-resizable notebook embed.
+# Figures wider than this are scaled down proportionally via CSS transform.
+# 860 px fits comfortably in a standard JupyterLab / VS Code notebook cell.
+MAX_NOTEBOOK_WIDTH = 860
+
# ---------------------------------------------------------------------------
# Trait serialisation
@@ -124,18 +129,51 @@ def _widget_px(widget) -> tuple[int, int]: