diff --git a/CHANGELOG.md b/CHANGELOG.md index 767459d..1fa150d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ All notable changes to qtviz are documented here. The format follows ### Added +- **Inset axes ([D152]–[D154], `design/inset-axes.md`):** `qv.Inset(child, + rect=(x0, y0, w, h), label=…, indicate=…)` — a child surface floating on a + parent, composed like an annotation: `overview * qv.Inset(zoom, + rect=(0.55, 0.55, 0.4, 0.4), label="zoom")`. `rect` is axes-fraction of + the parent's plot area; the child is a full surface (own title, lims, + scales via `.opts()`). On `Inset`, `label=` is the **pane label** (an + inset has no legend entry — it is chrome, like annotations): a labeled + inset is a pane, so its zoom window survives rebuilds and backend + switches via `LayoutState`, `view.pane("zoom").set_range(…)` drives it, + events from inside it carry `pane="zoom"`, and `pane.export(…)` writes + just the inset. `indicate=True` draws the parent-side rectangle marking + the child's declared x/y window. Rendered natively on pyqtgraph and + matplotlib; the webengine backend warns and skips insets for now (the + parent renders normally). + - **Structured axis sharing ([D146]):** `link_x`/`link_y` widen from bools to `bool | "col" | "row"` — `True` links all panes (unchanged), `"col"`/`"row"` link within each grid column/row, with spanning panes merging groups diff --git a/CLAUDE.md b/CLAUDE.md index 88d79b2..74de929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Test markers: `tier1` (pure core, no QApplication), `tier2` (Qt event loop + bac Layering (strict, one-directional — core never imports a concrete backend): - **`src/qtviz/core/`** — the spec's abstractions: `Element` (immutable, value-hashed, Qt-free declarative data), the **Mark IR** ([D121]/[D122]: `marks.py` — 9 typed drawing primitives in linear data space; `lowering.py` — `Element.lower(ctx) -> Lowered`), `Backend` protocol + `RenderContext`/`RenderHandle`/`ViewState` (`core/backend.py`), composition via operators (`a * b` → `Overlay`, `a + b` → `Layout`, in `compose.py`) with `.opts()` surface sugar ([D133]), `View` + `show()` (`core/view.py`), `Theme`/`Palette`/`Color`/`Norm`, typed events on an `EventBus`, `Capabilities` and backend negotiation, threading discipline. -- **`src/qtviz/elements/`** — the 28 element classes. Pure data constructors; a **tail of 14 lowers** (Quiver, Streamlines, Stem, Spread, Ecdf + the 9 annotations: one `lower()` in core, zero backend edits) and **14 stay native** (Scatter, Curve, Bars, Histogram, Area, BoxPlot, Violin, Image, Heatmap, Mesh, Contour, ErrorBars, Pie, RawFigure — engine idioms a lowering would visibly change; rationale in the 2.0 doc §8). +- **`src/qtviz/elements/`** — the 29 element classes. Pure data constructors; a **tail of 14 lowers** (Quiver, Streamlines, Stem, Spread, Ecdf + the 9 annotations: one `lower()` in core, zero backend edits), **14 stay native** (Scatter, Curve, Bars, Histogram, Area, BoxPlot, Violin, Image, Heatmap, Mesh, Contour, ErrorBars, Pie, RawFigure — engine idioms a lowering would visibly change; rationale in the 2.0 doc §8), and **Inset** ([D152]) is structural — a child surface drawn in the backends' surface loop, its labeled pane riding the [D147] machinery. - **`src/qtviz/data/`** — container-agnostic data layer. `DataRef` (tabular vs gridded), a priority-ordered adapter registry (dict/NumPy/pandas/Arrow eager; dask/xarray/zarr lazy), channel **accessors** (column name, serializable `Expression` via `col()`/`lit()`, callable, or raw array — **every channel keyword takes the full union, never just column names**), streaming sources, viewport regridding. `resolve_node` dispatches on `Element.DATA_KIND`; big-data side-channels ride the typed `_aux` slot (`RasterAux`/`GridAux`, [D124]). - **`src/qtviz/backends/{pyqtgraph,matplotlib,webengine}/`** — each implements the `Backend` protocol and registers through the **`qtviz.backends` entry-point group** ([D125]; a third-party backend needs zero qtviz edits). Each has a `_marks.py` adapter (~8 drawers, written once) for lowered elements — the pyqtgraph adapter is the one place its log pretransform lives — plus native renderers for the head elements; **a registered native renderer wins over lowering** (the fast-path override). Each maps `ViewState` to/from native ranges so pan/zoom/selection survive backend switches. The webengine backend hosts a Qt↔JS bridge (`core/`) with library extensions (`ext/`); `_runtime.py` files there are JavaScript embedded as Python strings (ruff E501 ignored, excluded from coverage). - **`src/qtviz/adapter/`** (holoviews/hvplot ingestion) and **`src/qtviz/ext/`** (datashader: large data → screen-resolution rasters that re-aggregate on zoom). diff --git a/design/inset-axes.md b/design/inset-axes.md new file mode 100644 index 0000000..30fa73d --- /dev/null +++ b/design/inset-axes.md @@ -0,0 +1,342 @@ +# Design note — inset axes + +> **Question.** How would qtviz incorporate matplotlib-style inset axes +> (`ax.inset_axes` + `indicate_inset_zoom`) — the last structural gap from the +> plot-organization comparison (`layout-panes-plan.md` §2) worth closing? +> +> **Short answer.** As a **structural element** — `qv.Inset(child, rect=…)` — +> composed into an overlay like an annotation (`parent * Inset(...)`), rendered +> natively per backend (all three have a native mechanism), and — the part +> that makes it more than parity — registered as a **pane** ([D147]): a +> labeled inset gets state capture/restore, `view.pane("zoom").set_range`, +> pane-scoped events, and per-pane export *for free* from the existing +> machinery. A portable, stateful, event-scoped inset is something matplotlib +> itself doesn't have. +> +> Thinking-through only — no code is changed by this note. Proposals numbered +> [D152]–[D154]. Companion to `pane-handles.md` (whose machinery this rides). + +--- + +## 1. What matplotlib provides (the target semantics) + +- `ax.inset_axes([x0, y0, w, h])` — a child Axes floating on a parent, placed + in **axes-fraction** coordinates (data-coords via `transform=` also exist). + A full Axes: any artist renders into it. Canonical uses: zoom insets, + mini-overviews, small context panels. +- `ax.indicate_inset_zoom(axins)` — a rectangle on the parent marking the + inset's view region, plus connector lines to the inset's corners. + +## 2. The qtviz shape — [D152] `Inset` as a structural element + +```python +overview = qv.Curve(d, x="t", y="v") +zoom = qv.Curve(d, x="t", y="v").opts(x=qv.AxisSpec(lim=(12.0, 14.0))) + +plot = overview * qv.Inset(zoom, rect=(0.55, 0.55, 0.4, 0.4), + label="zoom", indicate=True) +``` + +- **Node position:** an `Inset` rides an `Overlay`'s children like an + annotation does — `series_index_map` already treats annotation-class + children as chrome (no palette slot); `Inset` joins that class. `a * + Inset(...)` keeps the algebra: immutable, value-hashed (the child node + hashes like any node), `.opts()`-composable on both parent and child. +- **Fields:** `child: Node` (Element or Overlay — the inset's own surface, + with its own `OverlayOptions`/`AxisSpec`s), `rect: (x0, y0, w, h)` in + **axes-fraction** of the parent's plot area (validated ⊆ sane bounds; + data-coordinate placement deferred, §6), `label: str | None` (pane + identity, §4), `indicate: bool = False` (§5), plus a themed frame border. +- **Why an element and not an Overlay field:** one composition idiom + (`*`) instead of a parallel `insets=` channel; repr/inspection for free; + and the annotation precedent means backends already have the "chrome + child" branch to hang it on. +- **Two pipeline touches, declared not duck-typed:** + 1. `resolve_node` dispatches on `DATA_KIND`; `Inset` is `"none"` at its own + level but its `child` must resolve — one explicit recursion branch + (`Inset.child` → `resolve_node(child)`, copy-with), mirroring the + Overlay/Layout branch. `node_is_lazy` recurses the same way. + 2. `auto_negotiate`'s intersect-first rule ([D4]) must include inset-child + elements: an inset lives **on the parent's surface**, so it is + single-backend by construction — `_elements_of` learns to yield through + `Inset.child`. +- **Honesty:** `Inset` cannot lower — it is a sub-*surface*, not marks — so it + is a head element handled natively per backend, like the surface machinery + (twin axes) before it. Degradation is per-backend, visible, never silent + ([D51]): a backend that can't draw insets yet (webengine at first) + **warns and skips the inset**, parent rendering normally; a backend that + has never heard of insets fails `supports()` → a loud negotiation error. + (Part II drops the earlier `Capabilities.insets` flag idea — the render + path itself is the capability.) + +## 3. Per-backend rendering (all three have a native mechanism) + +| Backend | Mechanism | Effort / risk | +|---|---|---| +| matplotlib | `ax.inset_axes(rect)` — exactly our semantics; `apply_surface`/`apply_theme_ax` run on the inset ax like any surface | **low** | +| pyqtgraph | a child `PlotItem` (with a `QtvizViewBox`) added to the parent plot's scene, geometry = `rect` × the parent viewbox's pixel rect, recomputed on the parent's `geometryChanged`/resize signal | **medium** — the geometry-tracking hook is the one novel piece; everything inside the inset (renderers, events, R1 log handling) is the existing `_render_cell` re-entered with the inset plot as target | +| webengine (Plotly) | Plotly's native inset idiom: a second axis pair with `domain` fractions (`xaxis2: {domain: [0.55, 0.95]}`) and the child's traces bound to it | **medium-high** — `_figure.py` currently builds exactly one axis pair; adding axis2 touches the translator, relayout parsing (`xaxis2.range` events), and the R1 log map. The riskiest backend; can ship one release behind a capability gate without breaking "describe once" (it *warns*) | + +The render path is re-entrant by design: each backend's `_render_cell` +already takes "a node + a surface target"; rendering an inset is calling it +again with the inset's child and the inset's native surface. Theming, surface +config, legends, and event wiring all come along unchanged. + +## 4. [D153] An inset is a pane — the payoff + +This is where qtviz would exceed matplotlib rather than chase it. A labeled +inset joins the pane protocol: + +- `flat_pane_labels` learns to walk `Inset` children inside overlays (keeping + the single source of pane identity; global uniqueness validated as today — + an unlabeled inset gets a flat index). The backends' `plots`/`surfaces` + lists append the inset's surface, so the existing alignment invariant holds. +- Everything then rides for free, no new machinery: + - **State** ([D150]): the inset's view window is in `LayoutState` — a zoom + region **survives rebuilds, root swaps, and backend switches**. + - **Programmatic control** ([D147]): `view.pane("zoom").set_range(x=…)` + moves the zoom window; `autorange()`, `select()`, `.native`, `.elements`. + - **Events** ([D149]): pan/zoom *inside* the inset (pg's child + `QtvizViewBox` gets interaction for free; input hit-tests the top item, + so inset gestures don't pan the parent) emits `RangeEvent(pane="zoom")`; + `view.on(..., pane="zoom")` scopes to it. + - **Export**: `view.pane("zoom").export(...)`. +- Cross-machinery check: the [D151] link controller keys groups by pane + label, so an inset could even be *linked* to another pane — not a goal, + but nothing forbids it and nothing breaks. + +## 5. [D154] The zoom indicator + +`indicate=True` draws the parent-side rectangle marking the inset's current +x/y window. + +- **v1 — static:** drawn at render from the inset surface's declared + `AxisSpec.lim` (the 90% case: a declared zoom window). Implementation is + nearly free: the rectangle is data-space on the *parent* — exactly what the + existing `Rect` annotation lowering draws; the renderer synthesizes one. +- **v2 — live:** a small `_InsetIndicator` controller (the + `RasterController`/`_LinkController` house pattern): subscribe + `RangeEvent(pane=)` → update the parent's rectangle natively + (pg: move the `QGraphicsRectItem`; mpl: update the artist + `draw_idle`; + web: `relayout` a shape). Echo-safe trivially (it only *reads* events). +- **Connector lines: deliberately not proposed.** They need the inset's + *screen* position in the parent's *data* space — not expressible as + data-space marks, so they'd be per-backend chrome with visible parity + drift. matplotlib-only connectors would violate "describe once"; skipping + them uniformly keeps it honest. Revisit only if demand shows up. + +## 6. Deliberately deferred / rejected + +- **Data-coordinate `rect`** — deferred: placement must then re-layout on + every parent range change (another controller); axes-fraction covers the + canonical uses. +- **Insets inside insets** — rejected (depth 1, validated). +- **Mixed-backend insets** — rejected: same surface ⇒ same backend, by the + same rule as overlays. +- **Drag-to-move/resize insets** — rejected for now; `rect` is description. +- **Inset as a `Layout` concern** — rejected: wrong altitude; an inset is a + per-surface fact, panes are layout facts. + +## 7. Sequencing & obligations + +| Step | Contents | Notes | +|---|---|---| +| **I1** | `Inset` node: validation, resolve/negotiation recursion, `Capabilities.insets` | pure core, tier-1 | +| **I2** | mpl + pyqtgraph renderers (webengine gated w/ warn), theme/frame, conformance ("inset renders; child draws; parent unaffected; unsupported backend warns-and-skips") | the pg geometry hook is the one spike | +| **I3** | [D153] pane integration + tests (inset state survives a backend switch; pane-scoped events from inside the inset) | mostly wiring, big payoff | +| **I4** | [D154] static indicator; live-indicator controller as a follow-on gate | | +| **I5** | webengine domain-axes work → lift the gate | riskiest; independently shippable | + +Freeze/docs: `Inset` is a new public element → `FROZEN_2_0` amendment + +`api.md` + CHANGELOG in one commit ([D82]/[D135]); `HONORED_NATIVE` +declaration (`rect`, `label`, `indicate`); no benchmarks (render-time only — +stated so the omission is a decision). + +Open owner calls: (1) element-flavored `qv.Inset` via `*` (recommended) vs an +`Overlay.insets=` field; (2) ship I1–I3 with webengine gated (recommended) vs +holding for all-three parity; (3) `indicate` rectangle-only forever +(recommended) vs backend-native connectors where available. + +--- + +# Part II — concrete technical plan + +> **Status 2026-08-05: I1–I4 shipped** on `feat/inset-axes` (one commit per +> step + an opacity fix; gallery example `38_inset_zoom.py`). I3 needed no +> production changes beyond I2 — the pane machinery carried insets as +> designed. **I5 (webengine domain axes) remains gated** behind the warn-skip +> and awaits its own go. + +Written after the code-level walkthrough; adopts the §7 recommendations +(element via `*`; webengine gated behind a warn-skip; rectangle-only +indication) — flag before I1 lands if any should flip. Steps I1–I5 are +independently shippable, TDD per house cadence. + +## I1 — the `Inset` node (core; tier-1; public-surface commit) + +**`src/qtviz/elements/inset.py`** (new): + +```python +class Inset(Element): + DATA_KIND = "none" # data-less at its own level ([D124]) + STRUCTURAL_CHILD = "child" # declared child-node field (see below) + HONORED_NATIVE = frozenset({"rect", "label", "indicate"}) + + def __init__(self, child: Node, *, rect: tuple[float, float, float, float], + label: str | None = None, indicate: bool = False, + id: str | None = None) -> None: ... +``` + +- Validation: `rect` is `(x0, y0, w, h)` axes-fraction — `w, h > 0`, + `-0.5 <= x0, y0 <= 1.5` (matplotlib permits slight out-of-axes placement; + clamp-free but bounded); `child` is Element/Overlay (never Layout); **depth + 1**: walking `child`'s overlay children for another `Inset` raises + `ValidationError`. `label` non-empty when given. +- **`Element.STRUCTURAL_CHILD: str | None = None`** on the base — the [D124] + declared (not duck-typed) marker for "this data-less element carries a + child node". Consumed by: + - `data/pipeline.py::resolve_node` — before the `kind == "none"` + passthrough: if `STRUCTURAL_CHILD` is set, return + `node.with_(child=resolve_node(node.child))` (with_ preserves `id`). + `node_is_lazy` recurses the same way. + - `core/compose.py::_elements_of` — yield the Inset **and** recurse into + its child, so `auto_negotiate`'s intersect-first rule ([D4]) covers inset + contents and `negotiate`'s explicit-backend check errors early on an + unsupportable child. +- `series_index_map` (`core/compose.py`): Inset joins the chrome class — + concretely, the check becomes `isinstance(el, ANNOTATION_TYPES) or + getattr(el, "STRUCTURAL_CHILD", None)` (annotations import stays lazy). + No palette slot, no shift of following series; `legend_entry()` → `None`. +- Exports: `qtviz/__init__` + **`FROZEN_2_0` + `docs/api.md` + CHANGELOG in + the same commit** ([D82]/[D135]). +- No `Capabilities.insets` flag: the degradation story is per-backend + (I2's webengine warn-skip); a third-party backend that never learns about + insets fails `supports()` → loud negotiation error, which is honest. + +Tier-1 tests (`tests/qtviz/test_inset.py`): validation matrix (rect bounds, +depth-1, empty label, Layout child rejected); value-hash (rect/label/child +participate); resolve pipeline (a column-accessor child resolves; lazy child +→ `node_is_lazy` True); negotiation (`_elements_of` yields child elements; +auto excludes a backend that lacks a child element type). + +## I2 — renderers (pg + mpl native; webengine warn-skip) + +**Interception point — the overlay children loop, not the renderer +registry.** Both native backends special-case Inset exactly where the y2 +branch already lives, because the loop scope has everything an inset needs +(plot/ax, theme, bus, plots/surfaces, natives, labels). `supports()` gains +one clause on pg/mpl: `or issubclass(element_type, Inset)` (the registry +conformance test iterates registry types only — unaffected). + +**Label threading (shared, exact):** `_render_into` already computes +`labels = flat_pane_labels(node)`; it becomes a `deque` — each +`_render_cell` **pops one** for itself, and pops one more per Inset it +renders, in child order. Depth-first pop order is identical to I3's +`flat_pane_labels` walk by construction, so the plots/surfaces lists stay +aligned with pane identity (the existing defensive length check keeps +guarding it). + +- **matplotlib** (`backends/matplotlib/render.py`) — the easy one, ~15 lines: + + ```python + if isinstance(element, Inset): + iax = ax.inset_axes(element.rect) # axes-fraction, native + self._render_cell(element.child, iax, theme, raw_bus, surfaces, + natives, labels.popleft()) + natives[element.id] = iax + continue + ``` + + `_render_cell` re-entry gives the inset theming, `apply_surface` + (title/lims/scales), legends, `connect_range`/`connect_brush`, the surf + dict, and PaneBus stamping — all free. (`raw_bus`: pass the bus the cell + was handed; `PaneBus` stamps only when `pane is None`, so the inset's + inner proxy wins and re-wrapping is idempotent.) + +- **pyqtgraph** (`backends/pyqtgraph/render.py`) — refactor + the one spike: + 1. Split `_render_cell` into cell creation (grid `addPlot`) and + `_populate_plot(node, plot, vb, theme, bus, plots, natives, label)` + (surface apply + y2 + element loop + legend + `_qtviz_element_ids`). + Grid cells and insets both call `_populate_plot`. + 2. `_render_inset(inset, parent_plot, ...)`: + `vb = QtvizViewBox(bus=PaneBus(bus, label), surface_id=label, ...)`; + `iplot = pg.PlotItem(viewBox=vb)`; `iplot.setParentItem(parent_plot)`; + `iplot.setZValue(parent + 1)`; geometry from + `parent_plot.vb.geometry()` × rect fractions, recomputed on the parent + ViewBox's `sigResized` (**the spike** — verify offscreen geometry and + resize tracking before building on it; budget half a day, fall back to + `sigRangeChanged`+`geometryChanged` if `sigResized` proves unreliable); + then `plots.append(iplot)` and `_populate_plot(inset.child, iplot, …)`. + `style_plot` + a themed border (`iplot` frame pen = theme foreground at + low alpha) so the inset reads as a panel over data. +- **webengine** (`backends/webengine/_figure.py`): the trace-build loop + skips `Inset` children with a **warn-once** `QtvizWarning` + (`"webengine: inset axes not supported yet; inset {label!r} skipped"`), + parent renders normally. `supports()` clause added like pg/mpl so + negotiation still allows explicitly-chosen webengine. Headless-testable: + the built figure dict contains no inset traces + the warning fires. + +Tier-2 tests (parametrized pg/mpl): inset renders (native exists, parent's +own element count unchanged); inset surface options honored (mpl: +`iax.get_xlim() == lim`; pg: vb range == lim); nested-in-grid (an inset +inside a mosaic pane); webengine figure-dict skip + warning (headless). + +## I3 — insets are panes ([D153]; the payoff, mostly wiring) + +- `core/compose.py::flat_pane_labels`: after appending a leaf's label, walk + the leaf's overlay children (`n.children if isinstance(n, Overlay) else + (n,)`) for `Inset` elements and append `inset.label` (or flat index) — + depth-first, matching I2's pop order. Uniqueness validation unchanged + (inset labels join the same namespace). Lazy `elements` import, as + `series_index_map` already does. +- Backends: **nothing** — I2 appended the inset plot/surf to the exact lists + `_panes()` zips with `flat_pane_labels`, and `PgPane`/`MplPane` wrap inset + surfaces indistinguishably. +- Everything downstream is inherited: `LayoutState` (inset zoom window + survives rebuild/backend switch), `view.pane("zoom").set_range/autorange/ + select/native/elements/export`, `Event.pane == "zoom"` (the I2 PaneBus), + `view.on(pane="zoom")`, [D151] linking (labels are just labels). + +Tier-2 tests: `view.panes` order `["0", "zoom"]` for a single-surface parent +with one labeled inset; `pane("zoom").set_range` + capture; **inset window +survives `set_backend("pyqtgraph" ⇄ "matplotlib")`**; RangeEvent from the +inset carries `pane="zoom"`; `pane("zoom").elements == (child ids)`; +per-pane export writes the inset only. + +## I4 — the static zoom indicator ([D154] v1) + +In the parent's children loop, after rendering an inset with +`indicate=True`: read `surf = surface_of(inset.child)`; if **both** +`surf.x.lim` and `surf.y.lim` are declared, synthesize +`Rect(x0, y0, x1, y1, line_style="dashed")` (the existing wave-1 annotation +— data-space on the parent, lowers everywhere, zero new drawing code) and +render it through `_render_element` with the parent ctx; else warn-once +(`"indicate=True needs declared x/y lims on the inset until the live +indicator lands"`). The synthesized Rect gets no id in `natives` (chrome). + +Tier-2: indicator artist present at the declared lims on pg + mpl; missing +lims → warning, no rect. **I4b (separate gate):** the live +`_InsetIndicator` controller — subscribe `RangeEvent(pane=)`, move +the rect natively (`_LinkController` file pattern in `core/_host.py`… +except this one is per-backend-handle; place with the raster controllers). +Not scheduled until the static version proves demand. + +## I5 — webengine catch-up (independent; the risky one) + +`_figure.build` learns a second axis pair per inset: `xaxis2/yaxis2` with +`domain` from `rect`, child traces bound via `xaxis: "x2"`; `_translate` +learns `xaxis2.range` relayout parsing (R1 log map per axis pair); +`_WebPane` grows one pane per inset (shadow ranges per axis pair). Replaces +the warn-skip. Own spike + go/no-go; nothing in I1–I4 depends on it. + +## Cross-cutting obligations + +- Freeze triple lands in I1 (the name), CHANGELOG entries per step. +- `docs/api.md`: `::: qtviz.Inset` in the Elements section + a line in the + Panes section (I3). +- Gallery: extend `37_named_panes.py` (or a small `38_inset_zoom.py`) with a + zoom inset + indicator once I4 lands; regenerate that screenshot only. +- No benchmarks: render-time only, no per-frame path touched (stated per + cadence so the omission is a decision). +- Est. sizes: I1 **S–M**, I2 **M** (pg spike inside), I3 **S**, I4 **S**, + I5 **M–L**. diff --git a/docs/api.md b/docs/api.md index ab399f2..aa714e5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,6 +47,8 @@ one-liner; `qv.View` is the plain QWidget for applications. ::: qtviz.Streamlines +::: qtviz.Inset + ::: qtviz.RawFigure ## Annotation & reference elements diff --git a/docs/gallery.md b/docs/gallery.md index 45a8b01..8f34431 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -89,6 +89,15 @@ pane, `view.on(..., pane="price")` scopes events — [![Named panes: labeled mosaic, linked column, programmatic pane zoom](images/examples/37_named_panes.png)](images/examples/37_named_panes.png) +Inset axes — `overview * qv.Inset(zoom, rect=…, label="zoom", +indicate=True)`: a child surface floating on its parent with the zoom +window marked on the parent; the labeled inset is a pane +(`view.pane("zoom").set_range(…)`, pane-scoped events, per-pane export, +state that survives backend switches) — +[`38_inset_zoom.py`](https://github.com/jawjay/qtviz/blob/main/examples/38_inset_zoom.py) + +[![Inset axes: a zoom window floating on its parent, with the region marked](images/examples/38_inset_zoom.png)](images/examples/38_inset_zoom.png) + ## Getting started
diff --git a/docs/images/examples/38_inset_zoom.png b/docs/images/examples/38_inset_zoom.png new file mode 100644 index 0000000..f224234 Binary files /dev/null and b/docs/images/examples/38_inset_zoom.png differ diff --git a/examples/38_inset_zoom.py b/examples/38_inset_zoom.py new file mode 100644 index 0000000..726e1a2 --- /dev/null +++ b/examples/38_inset_zoom.py @@ -0,0 +1,56 @@ +"""Inset axes — a zoom window floating on its parent ([D152]–[D154], +design/inset-axes.md). + +`qv.Inset(child, rect=…)` composes into an overlay like an annotation: the +child is a full surface (own title, lims via `.opts()`), `rect` places it in +axes-fraction coordinates, and `indicate=True` draws the parent-side +rectangle marking the child's declared window. A **labeled** inset is a pane +— the same machinery as grid panes: + + view.pane("zoom").set_range(x=(20, 22)) # move the zoom window + view.on(qv.RangeEvent, cb, pane="zoom") # events from inside the inset + view.pane("zoom").export("zoom.png") # just the inset + +and its window survives rebuilds and `set_backend()` switches. Renders on +pyqtgraph and matplotlib; webengine warns-and-skips insets for now. + +Run: + uv run python examples/38_inset_zoom.py +""" + +from __future__ import annotations + +import numpy as np + +import qtviz as qv + +rng = np.random.default_rng(3) +t = np.linspace(0.0, 30.0, 800) +v = np.sin(t) + 0.1 * np.sin(40.0 * t) + rng.normal(0.0, 0.03, t.size) +d = {"t": t, "v": v} + +overview = qv.Curve(d, x="t", y="v").opts( + title="Signal — with a zoom inset", x="t [s]") +zoom = qv.Curve(d, x="t", y="v").opts( + title="12–14 s", + x=qv.AxisSpec(lim=(12.0, 14.0)), + y=qv.AxisSpec(lim=(-1.4, 1.4)), +) +root = overview * qv.Inset(zoom, rect=(0.58, 0.55, 0.4, 0.42), + label="zoom", indicate=True) + + +def build() -> qv.View: + return qv.View(root, backend="pyqtgraph") + + +def main() -> None: + view = build() + view.on(qv.RangeEvent, + lambda e: print(f"zoom window: x={e.x[0]:.2f}..{e.x[1]:.2f}"), + pane="zoom", throttle_ms=200) + qv.show(view, title="qtviz — inset zoom", size=(950, 560)) + + +if __name__ == "__main__": + main() diff --git a/src/qtviz/__init__.py b/src/qtviz/__init__.py index f99a17d..7387fe9 100644 --- a/src/qtviz/__init__.py +++ b/src/qtviz/__init__.py @@ -58,6 +58,7 @@ Histogram, HLine, Image, + Inset, Mesh, Pie, Polygon, @@ -90,6 +91,7 @@ "Area", "Ecdf", "Pie", "Contour", "Mesh", "Quiver", # waves 1.4/1.5 ([D115]/[D118]) "Stem", "Streamlines", + "Inset", # [D152] inset axes # the element base + node union — the downstream annotation vocabulary ([D140]) "Element", "Node", # composition + view + the [D134] script one-liner diff --git a/src/qtviz/backends/matplotlib/render.py b/src/qtviz/backends/matplotlib/render.py index f2e5ec6..f587333 100644 --- a/src/qtviz/backends/matplotlib/render.py +++ b/src/qtviz/backends/matplotlib/render.py @@ -219,9 +219,13 @@ def __init__(self) -> None: self.renderers.register(element_type, fn) def supports(self, element_type: type) -> bool: - # native registration, or a [D122] lowering the mark adapter can draw + # native registration, a [D122] lowering the mark adapter can draw, or + # a structural element handled in the surface loop (Inset, [D152]) if self.renderers.get(element_type) is not None: return True + if (issubclass(element_type, Element) + and getattr(element_type, "STRUCTURAL_CHILD", None)): + return True return (issubclass(element_type, Element) and element_type.lower is not Element.lower) @@ -232,6 +236,8 @@ def honored_options(self, element_type: type) -> frozenset[str]: return frozenset() if self.renderers.get(element_type) is not None: return element_type.HONORED_NATIVE - HONORED_DELTAS.get(element_type, frozenset()) + if getattr(element_type, "STRUCTURAL_CHILD", None): # Inset ([D152]) + return element_type.HONORED_NATIVE return element_type.HONORED_BY_LOWERING def can_host(self, kind: str) -> bool: @@ -269,9 +275,14 @@ def render(self, node, *, theme, parent=None) -> MplRenderHandle: "width_ratios", "height_ratios"}) def _render_into(self, node, fig, theme, bus, surfaces, natives) -> None: + from collections import deque # noqa: PLC0415 + from ...core.compose import flat_pane_labels # noqa: PLC0415 - labels = flat_pane_labels(node) # [D145]/[D149]: pane identity at render + # [D145]/[D149]: pane identity at render — a deque consumed in + # traversal order, one per surface and one per inset ([D152]), + # matching flat_pane_labels' depth-first walk. + labels = deque(flat_pane_labels(node)) if isinstance(node, Layout): from ...core.compose import grid_geometry # noqa: PLC0415 @@ -291,23 +302,28 @@ def _render_into(self, node, fig, theme, bus, surfaces, natives) -> None: for i in g[1:]} y_leader = {i: g[0] for g in link_groups(cells, n, opts.link_y) for i in g[1:]} - for i, (child, label, (r, c, rs, cs)) in enumerate( - zip(node.children, labels, cells, strict=True)): + leaders: list[int] = [] # surface index of each child's OWN ax: + for i, (child, (r, c, rs, cs)) in enumerate( + zip(node.children, cells, strict=True)): ax = fig.add_subplot( gs[r:r + rs, c:c + cs], - sharex=surfaces[x_leader[i]]["ax"] if i in x_leader else None, - sharey=surfaces[y_leader[i]]["ax"] if i in y_leader else None, + sharex=(surfaces[leaders[x_leader[i]]]["ax"] + if i in x_leader else None), + sharey=(surfaces[leaders[y_leader[i]]]["ax"] + if i in y_leader else None), ) - self._render_cell(child, ax, theme, bus, surfaces, natives, label) + leaders.append(len(surfaces)) # insets shift surfaces ([D152]) + self._render_cell(child, ax, theme, bus, surfaces, natives, labels) if opts.title: fig.suptitle(opts.title, color=theme.foreground.mpl(), fontsize=theme.title_size) else: self._render_cell(node, fig.add_subplot(1, 1, 1), theme, bus, surfaces, - natives, labels[0]) + natives, labels) def _render_cell(self, node, ax, theme, bus, surfaces, natives, - label: str = "0") -> None: + labels=None) -> None: + label = labels.popleft() if labels else "0" apply_theme_ax(ax, theme) surf = surface_of(node) check_surface(surf, consumer=self.name, honored=FULL_SURFACE) # ([D109]) @@ -333,9 +349,12 @@ def _render_cell(self, node, ax, theme, bus, surfaces, natives, apply_y2(ax2, y2_spec, theme, y2_scale) entry = {"ax": ax, "surface_id": surface_id, "selectables": selectables, "y2_ax": ax2, "bus": bus, - # pane → element map ([D147]): MplPane.elements reads this - "element_ids": tuple(el.id for el in children - if isinstance(el, Element))} + # pane → element map ([D147]): MplPane.elements reads this. + # Insets are chrome here — their contents list on their OWN pane. + "element_ids": tuple( + el.id for el in children + if isinstance(el, Element) + and not getattr(el, "STRUCTURAL_CHILD", None))} surfaces.append(entry) _events.connect_range(ax, surface_id, bus) indices = series_index_map(children) # palette slots; annotations excluded @@ -344,6 +363,16 @@ def _render_cell(self, node, ax, theme, bus, surfaces, natives, show_legend=surf.legend_enabled, legend_position=surf.legend_position) for element, si in zip(children, indices, strict=True): + if getattr(element, "STRUCTURAL_CHILD", None): # an Inset ([D152]) + iax = ax.inset_axes(list(element.rect)) # native, mpl semantics + natives[element.id] = iax # [D53]: the inset's live Axes + self._render_cell(element.child, iax, theme, bus, surfaces, + natives, labels) + marker = element.indicator() # [D154] static zoom rectangle + if marker is not None: + self._render_element(marker, replace(ctx, series_index=0), + selectables, natives) + continue on_y2 = getattr(element, "axis", "y") == "y2" el_ctx = replace(ctx, series_index=si, parent_axes=ax2 if on_y2 else ax, diff --git a/src/qtviz/backends/pyqtgraph/render.py b/src/qtviz/backends/pyqtgraph/render.py index 9fb7423..25d74bb 100644 --- a/src/qtviz/backends/pyqtgraph/render.py +++ b/src/qtviz/backends/pyqtgraph/render.py @@ -272,9 +272,13 @@ def __init__(self) -> None: self._last_theme = None def supports(self, element_type: type) -> bool: - # native registration, or a [D122] lowering the mark adapter can draw + # native registration, a [D122] lowering the mark adapter can draw, or + # a structural element handled in the surface loop (Inset, [D152]) if self.renderers.get(element_type) is not None: return True + if (issubclass(element_type, Element) + and getattr(element_type, "STRUCTURAL_CHILD", None)): + return True return (issubclass(element_type, Element) and element_type.lower is not Element.lower) @@ -285,6 +289,8 @@ def honored_options(self, element_type: type) -> frozenset[str]: return frozenset() if self.renderers.get(element_type) is not None: return element_type.HONORED_NATIVE - HONORED_DELTAS.get(element_type, frozenset()) + if getattr(element_type, "STRUCTURAL_CHILD", None): # Inset ([D152]) + return element_type.HONORED_NATIVE return element_type.HONORED_BY_LOWERING def can_host(self, kind: str) -> bool: @@ -309,9 +315,14 @@ def render(self, node, *, theme, parent=None) -> PgRenderHandle: "width_ratios", "height_ratios"}) def _render_into(self, node, widget, theme, bus, plots, natives) -> None: + from collections import deque # noqa: PLC0415 + from ...core.compose import flat_pane_labels # noqa: PLC0415 - labels = flat_pane_labels(node) # [D145]/[D149]: pane identity at render + # [D145]/[D149]: pane identity at render. A deque consumed in traversal + # order — one per surface, one per inset ([D152]) — provably matching + # flat_pane_labels' depth-first walk. + labels = deque(flat_pane_labels(node)) if isinstance(node, Layout): from ...core.compose import grid_geometry # noqa: PLC0415 @@ -324,10 +335,9 @@ def _render_into(self, node, widget, theme, bus, plots, natives) -> None: color=theme.foreground.hex(), size=f"{theme.title_size}pt") row0 = 1 - for child, label, (r, c, rs, cs) in zip(node.children, labels, cells, - strict=True): + for child, (r, c, rs, cs) in zip(node.children, cells, strict=True): self._render_cell(child, widget, theme, bus, plots, natives, - r + row0, c, rowspan=rs, colspan=cs, label=label) + r + row0, c, rowspan=rs, colspan=cs, labels=labels) grid = widget.ci.layout # QGraphicsGridLayout: integer stretches for c, ratio in enumerate(opts.width_ratios or ()): grid.setColumnStretchFactor(c, max(1, round(ratio * 100))) @@ -337,23 +347,36 @@ def _render_into(self, node, widget, theme, bus, plots, natives) -> None: link_axes(plots, cells=cells, link_x=opts.link_x, link_y=opts.link_y) else: self._render_cell(node, widget, theme, bus, plots, natives, 0, 0, - label=labels[0]) + labels=labels) # [D109]: everything except tick label rotation (no stable AxisItem API). SURFACE_HONORED = FULL_SURFACE - {"x.tick_rotation", "y.tick_rotation"} - def _render_cell(self, node, widget, theme, bus, plots, natives, row, col, - *, rowspan: int = 1, colspan: int = 1, label: str = "0") -> None: + def _surface_target(self, node, theme, bus, label): + """Surface config + a wired ViewBox for one pane; the caller parents + the `PlotItem` (grid cell or inset). [D149]: the pane label IS the + surface id and every emit through the stamping bus carries it.""" surf = surface_of(node) check_surface(surf, consumer=self.name, honored=self.SURFACE_HONORED) x_scale, y_scale = effective_scales(node, surf, self.capabilities.scales, self.name) - # [D149]: the pane label IS the surface id (RangeEvent/TapEvent - # source_id) and every emit through the stamping bus carries pane=label. - bus = PaneBus(bus, label) - vb = QtvizViewBox(bus=bus, surface_id=label, + pane_bus = PaneBus(bus, label) + vb = QtvizViewBox(bus=pane_bus, surface_id=label, x_log=(x_scale == "log"), y_log=(y_scale == "log")) + return surf, x_scale, y_scale, pane_bus, vb + + def _render_cell(self, node, widget, theme, bus, plots, natives, row, col, + *, rowspan: int = 1, colspan: int = 1, labels=None) -> None: + label = labels.popleft() if labels else "0" + surf, x_scale, y_scale, pane_bus, vb = self._surface_target(node, theme, bus, label) plot = widget.addPlot(row=row, col=col, rowspan=rowspan, colspan=colspan, viewBox=vb) + self._populate_plot(node, plot, vb, surf, x_scale, y_scale, pane_bus, + theme, bus, plots, natives, labels) + + def _populate_plot(self, node, plot, vb, surf, x_scale, y_scale, bus, theme, + raw_bus, plots, natives, labels) -> None: + """Everything inside one surface — shared by grid cells and insets + ([D152]): theming, surface apply, y2, the element loop, legend.""" style_plot(plot, theme) apply_surface(plot, surf, theme, x_scale, y_scale) plots.append(plot) @@ -371,20 +394,30 @@ def _render_cell(self, node, widget, theme, bus, plots, natives, row, col, vb2 = make_y2(plot, vb, y2_spec, theme, x_scale, y2_scale) plot._qtviz_vb2 = vb2 y2_host = _Y2Host(plot, vb2) - indices = series_index_map(children) # palette slots; annotations excluded + indices = series_index_map(children) # palette slots; chrome excluded ctx = RenderContext(theme=theme, parent=plot, event_bus=bus, backend=self, parent_axes=plot, x_scale=x_scale, y_scale=y_scale, show_legend=surf.legend_enabled, legend_position=surf.legend_position) for element, si in zip(children, indices, strict=True): + if getattr(element, "STRUCTURAL_CHILD", None): # an Inset ([D152]) + self._render_inset(element, plot, theme, raw_bus, plots, + natives, labels) + marker = element.indicator() # [D154] static zoom rectangle + if marker is not None: + self._render_element(marker, replace(ctx, series_index=0), + natives) + continue on_y2 = getattr(element, "axis", "y") == "y2" el_ctx = replace(ctx, series_index=si, parent_axes=y2_host if on_y2 else plot, y_scale=y2_scale if on_y2 else y_scale) self._render_element(element, el_ctx, natives) - # pane → element map ([D147]): PgPane.elements reads this off the item + # pane → element map ([D147]): PgPane.elements reads this off the item. + # Insets are chrome here — their contents list on their OWN pane. plot._qtviz_element_ids = tuple( - el.id for el in children if isinstance(el, Element)) + el.id for el in children + if isinstance(el, Element) and not getattr(el, "STRUCTURAL_CHILD", None)) # Overlay legend aggregation ([D60]): each child contributes its # legend_entry(); merged into any color-mapping legend already drawn. if surf.legend_enabled: @@ -396,6 +429,45 @@ def _render_cell(self, node, widget, theme, bus, plots, natives, row, col, append_legend_entries(plot, entries, theme, surf.legend_position) + def _render_inset(self, inset, parent_plot, theme, raw_bus, plots, natives, + labels) -> None: + """A child `PlotItem` floating on the parent ([D152], spiked): geometry + is `rect` (axes-fraction, y from the BOTTOM — mpl semantics; Qt item + coords run y-down, hence the flip) of the parent ViewBox's rect, + re-placed on the parent's `sigResized`.""" + from PySide6.QtCore import QRectF # noqa: PLC0415 + + label = labels.popleft() if labels else str(len(plots)) + surf, x_scale, y_scale, pane_bus, vb = self._surface_target( + inset.child, theme, raw_bus, label) + iplot = pg.PlotItem(viewBox=vb) + iplot.setParentItem(parent_plot) + iplot.setZValue(parent_plot.zValue() + 1) + # opaque panel over the WHOLE inset rect (axes margins included): + # without it the parent's curves/grid show through (matplotlib's + # inset facecolor is opaque — parity). Spiked: autoFillBackground + + # a themed Window palette occludes cleanly. + from PySide6.QtGui import QColor, QPalette # noqa: PLC0415 + + pal = QPalette() + pal.setColor(QPalette.ColorRole.Window, QColor(theme.background.hex())) + iplot.setPalette(pal) + iplot.setAutoFillBackground(True) + x0, y0, fw, fh = inset.rect + parent_vb = parent_plot.vb + + def _place(*_a, _ip=iplot, _pv=parent_vb) -> None: + r = _pv.geometry() # the parent's plot area, in parent item coords + _ip.setGeometry(QRectF(r.x() + x0 * r.width(), + r.y() + (1.0 - y0 - fh) * r.height(), + fw * r.width(), fh * r.height())) + + parent_vb.sigResized.connect(_place) + _place() + natives[inset.id] = iplot # [D53]: the inset's live PlotItem + self._populate_plot(inset.child, iplot, vb, surf, x_scale, y_scale, + pane_bus, theme, raw_bus, plots, natives, labels) + def _render_element(self, element: Element, ctx, natives) -> None: fn = self.renderers.get(type(element)) # native fast path wins ([D122]) if fn is None and type(element).lower is not Element.lower: diff --git a/src/qtviz/backends/webengine/_figure.py b/src/qtviz/backends/webengine/_figure.py index 786851c..2b3f692 100644 --- a/src/qtviz/backends/webengine/_figure.py +++ b/src/qtviz/backends/webengine/_figure.py @@ -787,6 +787,19 @@ def build(node, theme) -> tuple[dict, list[str]]: raise IncompatibleOverlayError( "RawFigure is a whole figure and can't be overlaid; render it on its own" ) + if getattr(element, "STRUCTURAL_CHILD", None): # an Inset ([D152]) + # design/inset-axes.md I5: Plotly domain-axes support is its own + # step — until then the inset warns and is skipped, the parent + # renders normally (visible degradation, never silent, [D51]). + import warnings # noqa: PLC0415 + + from ...errors import QtvizWarning # noqa: PLC0415 + + warnings.warn( + f"webengine: inset axes are not supported yet; inset " + f"{getattr(element, 'label', None)!r} skipped (renders on " + f"pyqtgraph/matplotlib).", QtvizWarning, stacklevel=2) + continue check_recommended( element, backend_name="webengine", honored=honored_for(type(element)), ) diff --git a/src/qtviz/backends/webengine/render.py b/src/qtviz/backends/webengine/render.py index 99e98bb..aeb9b99 100644 --- a/src/qtviz/backends/webengine/render.py +++ b/src/qtviz/backends/webengine/render.py @@ -258,6 +258,11 @@ def supports(self, element_type: type) -> bool: return True from ...core.element import Element # noqa: PLC0415 + if (issubclass(element_type, Element) + and getattr(element_type, "STRUCTURAL_CHILD", None)): + # Inset ([D152]): accepted so negotiation proceeds; the figure + # builder warns-and-skips it until I5 (design/inset-axes.md). + return True return (issubclass(element_type, Element) and element_type.lower is not Element.lower) diff --git a/src/qtviz/core/compose.py b/src/qtviz/core/compose.py index 8a4b4c1..2549f25 100644 --- a/src/qtviz/core/compose.py +++ b/src/qtviz/core/compose.py @@ -437,6 +437,13 @@ def flat_pane_labels(node: Node) -> tuple[str, ...]: given: list[str | None] = [] + def leaf(n: Node, lb: str | None) -> None: + given.append(lb) # the surface itself … + kids = n.children if isinstance(n, Overlay) else (n,) + for el in kids: # … then its insets, in child order ([D152]/[D153]) + if getattr(el, "STRUCTURAL_CHILD", None): + given.append(getattr(el, "label", None)) + def walk(n: Node) -> None: if isinstance(n, Layout): labels = n.labels or (None,) * len(n.children) @@ -444,9 +451,9 @@ def walk(n: Node) -> None: if isinstance(child, Layout): walk(child) else: - given.append(lb) + leaf(child, lb) else: - given.append(None) + leaf(n, None) walk(node) out = [lb if lb is not None else str(i) for i, lb in enumerate(given)] @@ -567,7 +574,9 @@ def series_index_map(children) -> list[int]: out: list[int] = [] i = 0 for el in children: - if isinstance(el, ANNOTATION_TYPES): + # chrome: annotations and structural elements (an Inset, [D152]) take + # no palette slot and don't shift the series that follow + if isinstance(el, ANNOTATION_TYPES) or getattr(el, "STRUCTURAL_CHILD", None): out.append(0) else: out.append(i) @@ -578,6 +587,8 @@ def series_index_map(children) -> list[int]: def _elements_of(node: Node) -> Iterator[Element]: if isinstance(node, Element): yield node + if node.STRUCTURAL_CHILD is not None: # [D152]: an Inset's contents + yield from _elements_of(getattr(node, node.STRUCTURAL_CHILD)) elif isinstance(node, (Overlay, Layout)): for child in node.children: yield from _elements_of(child) @@ -625,6 +636,13 @@ def negotiate(node: Node, view_backend: str | None, *, ancestor_hint: str | None f"{type(node).__name__} not supported on {chosen!r}; " f"supported on: {supported}" ) + if node.STRUCTURAL_CHILD is not None: # [D152]: an Inset's contents render + child = getattr(node, node.STRUCTURAL_CHILD) # on the SAME surface — + inner = negotiate(child, view_backend, ancestor_hint=chosen) + if inner != chosen: # — so the same backend, like overlay children + raise IncompatibleOverlayError( + f"an inset renders on its parent's surface; its contents " + f"resolve to {inner!r} but the surface is {chosen!r}") return chosen @@ -652,6 +670,15 @@ def auto_negotiate(node: Node, *, ancestor_hint: str | None = None) -> str: auto_negotiate(child) return "auto" + if getattr(node, "STRUCTURAL_CHILD", None): # [D152]: intersect over contents + elems = list(_elements_of(node)) + candidates = [b for b in backends.registered() + if all(b.supports(type(e)) for e in elems)] + if not candidates: + raise NoBackendForError( + "no single backend supports the inset and its contents: " + f"{sorted({type(e).__name__ for e in elems})}") + return _pick(candidates, max((_data_size(e) or 0) for e in elems)) candidates = [b for b in backends.registered() if b.supports(type(node))] if not candidates: raise NoBackendForError(f"no registered backend supports {type(node).__name__}") diff --git a/src/qtviz/core/element.py b/src/qtviz/core/element.py index 0281ab6..f7256bb 100644 --- a/src/qtviz/core/element.py +++ b/src/qtviz/core/element.py @@ -52,6 +52,11 @@ class Element(Immutable): # declares how it consumes data — the resolve pipeline dispatches on it. data: Any = None DATA_KIND: str = "tabular" # "tabular" | "gridded" | "none" + # [D152] declared (never duck-typed) marker for a data-less *structural* + # element that carries a child NODE under this field name (Inset.child): + # the resolve pipeline recurses into it and negotiation intersects over + # its elements. + STRUCTURAL_CHILD: str | None = None REQUIRED_OPTIONS: tuple[str, ...] = () RECOMMENDED_OPTIONS: tuple[str, ...] = () # Fixed channel roles bound to accessors; default role == field name. diff --git a/src/qtviz/data/pipeline.py b/src/qtviz/data/pipeline.py index a2be4c0..8a1e01f 100644 --- a/src/qtviz/data/pipeline.py +++ b/src/qtviz/data/pipeline.py @@ -129,6 +129,10 @@ def resolve_node(node): Dispatch is the [D124] `DATA_KIND` declaration, not duck-typing.""" kind = getattr(node, "DATA_KIND", None) if kind is not None: # Element + child_field = node.STRUCTURAL_CHILD # [D152]: Inset carries a child node + if child_field is not None: + return node.with_( + **{child_field: resolve_node(getattr(node, child_field))}) if kind == "none" or getattr(node, "_resolved", False): return node # data-less (annotations, RawFigure) pass through if _needs_rasterize(node): @@ -161,6 +165,9 @@ def node_is_lazy(node) -> bool: or a datashader rasterization.""" kind = getattr(node, "DATA_KIND", None) if kind is not None: + child_field = node.STRUCTURAL_CHILD # [D152] + if child_field is not None: + return node_is_lazy(getattr(node, child_field)) if kind == "none": return False return (bool(getattr(node.data, "is_lazy", False)) diff --git a/src/qtviz/elements/__init__.py b/src/qtviz/elements/__init__.py index ca4e74d..4da5004 100644 --- a/src/qtviz/elements/__init__.py +++ b/src/qtviz/elements/__init__.py @@ -12,6 +12,7 @@ from .heatmap import Heatmap from .histogram import Histogram from .image import Image +from .inset import Inset from .mesh import Mesh from .pie import Pie from .quiver import Quiver @@ -32,4 +33,5 @@ "BoxPlot", "Violin", "Area", "Ecdf", "Pie", "Contour", "Mesh", "Quiver", "Stem", "Streamlines", + "Inset", ] diff --git a/src/qtviz/elements/inset.py b/src/qtviz/elements/inset.py new file mode 100644 index 0000000..73514c0 --- /dev/null +++ b/src/qtviz/elements/inset.py @@ -0,0 +1,91 @@ +"""`Inset` — a child surface floating on a parent surface ([D152]). + +The qtviz shape of matplotlib's `ax.inset_axes`: a **structural element** +composed into an overlay like an annotation (`parent * Inset(child, +rect=…)`). The child is a full surface tree (an Element or Overlay with its +own `OverlayOptions`/`AxisSpec`s) placed in **axes-fraction** coordinates of +the parent's plot area. An `Inset` cannot lower — it is a sub-*surface*, not +marks — so each backend renders it natively (`design/inset-axes.md` Part II). + +A **labeled** inset is a pane ([D153]): it joins `flat_pane_labels`, so its +zoom window rides `LayoutState` across rebuilds and backend switches, +`view.pane("zoom").set_range(…)` drives it, events from inside it carry +`pane="zoom"`, and `pane.export(…)` writes just the inset. `indicate=True` +draws the parent-side rectangle marking the child's declared x/y window +([D154] — the static v1; both lims must be set on the child's surface). +""" + +from __future__ import annotations + +from ..core.element import Element +from ..errors import ValidationError + + +class Inset(Element): + DATA_KIND = "none" # data-less at its own level ([D124]) … + STRUCTURAL_CHILD = "child" # … but carries a child NODE the pipeline resolves + HONORED_NATIVE = frozenset({"rect", "label", "indicate"}) + + def __init__(self, child, *, rect: tuple[float, float, float, float], + label: str | None = None, indicate: bool = False, + backend_hint: str | None = None, id: str | None = None) -> None: + from ..core.compose import Layout, Overlay # noqa: PLC0415 — avoid a cycle + + super().__init__(backend_hint=backend_hint, id=id) + if isinstance(child, Layout): + raise ValidationError( + "Inset child must be an Element or Overlay (one surface); " + "for multiple panes use Layout, not an inset") + if not isinstance(child, (Element, Overlay)): + raise ValidationError( + f"Inset child must be an Element or Overlay, got {type(child).__name__}") + # depth 1: an inset holding an inset is rejected, not rendered badly + inner = child.children if isinstance(child, Overlay) else (child,) + if any(isinstance(el, Inset) for el in inner): + raise ValidationError("insets do not nest (depth 1)") + r = tuple(float(v) for v in rect) + if len(r) != 4: + raise ValidationError(f"rect must be (x0, y0, w, h), got {rect!r}") + x0, y0, w, h = r + if w <= 0 or h <= 0: + raise ValidationError(f"rect width/height must be > 0, got {rect!r}") + if not (-0.5 <= x0 <= 1.5 and -0.5 <= y0 <= 1.5): + raise ValidationError( + f"rect origin is axes-fraction of the parent plot area; " + f"{(x0, y0)!r} is out of the sane (-0.5..1.5) band") + if label is not None and not label: + raise ValidationError("inset label must be a non-empty string") + self.child = child + self.rect = r + self.label = label + self.indicate = bool(indicate) + self._freeze() + + def legend_entry(self, theme, index: int = 0): + return None # chrome: an inset never contributes to the parent legend + + def indicator(self): + """[D154] static v1: the parent-side `Rect` marking the child's + declared x/y window, or `None`. Requires both lims on the child's + surface (`.opts(x=AxisSpec(lim=…), y=AxisSpec(lim=…))`) — the + declared window IS the zoom region; a live indicator that follows + interactive pans inside the inset is a gated follow-on.""" + if not self.indicate: + return None + from ..core.compose import surface_of # noqa: PLC0415 + + surf = surface_of(self.child) + xl, yl = surf.x.lim, surf.y.lim + if xl is None or yl is None: + import warnings # noqa: PLC0415 + + from ..errors import QtvizWarning # noqa: PLC0415 + + warnings.warn( + "Inset(indicate=True) needs declared x AND y lims on the " + "child's surface to place the zoom rectangle; indicator " + "skipped.", QtvizWarning, stacklevel=3) + return None + from .shapes import Rect # noqa: PLC0415 + + return Rect(xl[0], yl[0], xl[1], yl[1], alpha=0.8) diff --git a/tests/qtviz/test_api_freeze.py b/tests/qtviz/test_api_freeze.py index 5eaff8c..b42a2cd 100644 --- a/tests/qtviz/test_api_freeze.py +++ b/tests/qtviz/test_api_freeze.py @@ -32,6 +32,7 @@ "Mesh", "Quiver", # wave 3 ([D106]/[D107]) "Stem", # wave 1.4 ([D115]) "Streamlines", # wave 1.5 ([D118]) + "Inset", # inset axes ([D152]) # the element base + node union ([D140]) "Element", "Node", # composition + view diff --git a/tests/qtviz/test_channel_vocabulary.py b/tests/qtviz/test_channel_vocabulary.py index eeae432..313c5b0 100644 --- a/tests/qtviz/test_channel_vocabulary.py +++ b/tests/qtviz/test_channel_vocabulary.py @@ -38,8 +38,8 @@ ) -def test_all_28_elements_are_covered(): - assert len(ELEMENT_TYPES) == 28 +def test_all_29_elements_are_covered(): + assert len(ELEMENT_TYPES) == 29 # 28 + Inset ([D152]) @pytest.mark.parametrize("et", ELEMENT_TYPES, ids=lambda t: t.__name__) diff --git a/tests/qtviz/test_example_mains.py b/tests/qtviz/test_example_mains.py index d5af8ed..7b751c5 100644 --- a/tests/qtviz/test_example_mains.py +++ b/tests/qtviz/test_example_mains.py @@ -38,6 +38,7 @@ "examples/31_axis_labels.py", "examples/35_everyday_figures.py", "examples/37_named_panes.py", + "examples/38_inset_zoom.py", ]) def test_example_main_runs_without_a_preexisting_app(example): result = subprocess.run( diff --git a/tests/qtviz/test_inset.py b/tests/qtviz/test_inset.py new file mode 100644 index 0000000..57cd88b --- /dev/null +++ b/tests/qtviz/test_inset.py @@ -0,0 +1,248 @@ +"""Inset axes ([D152]–[D154], design/inset-axes.md). + +I1 (tier-1): the node, validation, pipeline recursion, negotiation. +I2–I4 (tier-2): rendering, pane integration, and the static indicator live +in the sections below as those steps land. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +qv = pytest.importorskip("qtviz") + +from qtviz.core.compose import _elements_of # noqa: E402 +from qtviz.data import node_is_lazy, resolve_node # noqa: E402 +from qtviz.errors import ValidationError # noqa: E402 + +D = {"x": np.arange(10.0), "y": np.arange(10.0) ** 2} + + +def _s(**kw): + return qv.Scatter(D, x="x", y="y", **kw) + + +def _zoom(): + return qv.Curve(D, x="x", y="y").opts(x=qv.AxisSpec(lim=(2.0, 4.0)), + y=qv.AxisSpec(lim=(4.0, 16.0))) + + +# ── I1: the node ───────────────────────────────────────────────────────────── +@pytest.mark.tier1 +def test_inset_validation(): + ok = qv.Inset(_zoom(), rect=(0.5, 0.5, 0.4, 0.4), label="zoom") + assert ok.rect == (0.5, 0.5, 0.4, 0.4) and ok.label == "zoom" + with pytest.raises(ValidationError, match="Element or Overlay"): + qv.Inset(qv.Layout([_s()]), rect=(0, 0, 0.5, 0.5)) + with pytest.raises(ValidationError, match="Element or Overlay"): + qv.Inset("not a node", rect=(0, 0, 0.5, 0.5)) + with pytest.raises(ValidationError, match="depth 1"): + qv.Inset(_s() * qv.Inset(_s(), rect=(0, 0, 0.3, 0.3)), + rect=(0, 0, 0.5, 0.5)) + with pytest.raises(ValidationError, match="width/height"): + qv.Inset(_s(), rect=(0.1, 0.1, 0.0, 0.5)) + with pytest.raises(ValidationError, match="sane"): + qv.Inset(_s(), rect=(3.0, 0.1, 0.5, 0.5)) + with pytest.raises(ValidationError, match="non-empty"): + qv.Inset(_s(), rect=(0, 0, 0.5, 0.5), label="") + + +@pytest.mark.tier1 +def test_inset_value_identity(): + child = _s() + a = qv.Inset(child, rect=(0.5, 0.5, 0.4, 0.4), label="z") + assert a == qv.Inset(child, rect=(0.5, 0.5, 0.4, 0.4), label="z") + assert a != qv.Inset(child, rect=(0.1, 0.5, 0.4, 0.4), label="z") + assert a != qv.Inset(child, rect=(0.5, 0.5, 0.4, 0.4), label="w") + assert a != qv.Inset(child, rect=(0.5, 0.5, 0.4, 0.4), label="z", + indicate=True) + + +@pytest.mark.tier1 +def test_resolve_recurses_into_child(): + inset = qv.Inset(_s(), rect=(0.5, 0.5, 0.4, 0.4)) + node = _s() * inset + resolved = resolve_node(node) + inner = [el for el in _elements_of(resolved) if isinstance(el, qv.Scatter)] + assert len(inner) == 2 + for el in inner: # both the parent scatter AND the inset's resolved + assert el.data.resolve_channels(el.channels())["x"].shape == (10,) + + +@pytest.mark.tier1 +def test_lazy_child_marks_node_lazy(): + dd = pytest.importorskip("dask.dataframe") + pd = pytest.importorskip("pandas") + df = dd.from_pandas(pd.DataFrame({"x": np.arange(10.0), + "y": np.arange(10.0)}), npartitions=1) + lazy = qv.Scatter(df, x="x", y="y") + assert node_is_lazy(lazy) # premise: a dask-backed ref is lazy + assert node_is_lazy(_s() * qv.Inset(lazy, rect=(0, 0, 0.4, 0.4))) + assert not node_is_lazy(_s() * qv.Inset(_s(), rect=(0, 0, 0.4, 0.4))) + + +@pytest.mark.tier1 +def test_negotiation_sees_inset_contents(): + # a RawFigure only renders on webengine; hiding one inside an inset must + # still steer/inhibit negotiation ([D4] intersect-first via _elements_of) + raw = qv.RawFigure({"data": [], "layout": {}}, kind="plotly") + node = _s() * qv.Inset(raw, rect=(0, 0, 0.4, 0.4)) + assert raw in list(_elements_of(node)) + with pytest.raises(qv.errors.QtvizError): + qv.core.compose.negotiate(node, "pyqtgraph") # pg can't draw RawFigure + + +@pytest.mark.tier1 +def test_inset_is_chrome_in_series_indexing(): + from qtviz.core.compose import series_index_map + + s1, s2 = _s(), _s() + children = (s1, qv.Inset(_s(), rect=(0, 0, 0.4, 0.4)), s2) + assert series_index_map(children) == [0, 0, 1] # inset shifts nothing + assert children[1].legend_entry(qv.Theme.light()) is None + + +# ── I2: rendering ──────────────────────────────────────────────────────────── +@pytest.mark.tier2 +@pytest.mark.parametrize("name", ["pyqtgraph", "matplotlib"]) +def test_inset_renders_on_native_backends(name, qtbot): + view = qv.View(_s() * qv.Inset(_zoom(), rect=(0.55, 0.55, 0.4, 0.4), + label="zoom"), backend=name) + qtbot.addWidget(view) + inset_native = view.native([el for el in _elements_of(view.root) + if isinstance(el, qv.Inset)][0].id) + assert inset_native is not None # the live inset surface ([D53]) + + +@pytest.mark.tier2 +def test_mpl_inset_honors_child_surface(qtbot): + view = qv.View(_s() * qv.Inset(_zoom(), rect=(0.5, 0.5, 0.45, 0.45), + label="zoom"), backend="matplotlib") + qtbot.addWidget(view) + iax = view.pane("zoom").native + assert tuple(iax.get_xlim()) == pytest.approx((2.0, 4.0)) + assert tuple(iax.get_ylim()) == pytest.approx((4.0, 16.0)) + + +@pytest.mark.tier2 +def test_pg_inset_tracks_parent_geometry(qtbot): + view = qv.View(_s() * qv.Inset(_zoom(), rect=(0.5, 0.5, 0.4, 0.4), + label="zoom"), backend="pyqtgraph") + qtbot.addWidget(view) + view.resize(800, 600) + view.show() + iplot = view.pane("zoom").native + w1 = iplot.geometry().width() + view.resize(1200, 900) + qtbot.waitUntil(lambda: iplot.geometry().width() > w1, timeout=2000) + + +@pytest.mark.tier2 +def test_inset_in_a_grid_pane(qtbot): + lay = qv.Layout.grid({ + "main": _s() * qv.Inset(_zoom(), rect=(0.5, 0.5, 0.4, 0.4), label="zoom"), + "side": qv.Curve(D, x="x", y="y"), + }) + view = qv.View(lay, backend="pyqtgraph") + qtbot.addWidget(view) + assert [p.label for p in view.panes] == ["main", "zoom", "side"] + + +@pytest.mark.tier2 +def test_webengine_figure_skips_inset_with_warning(): + pytest.importorskip("plotly") + from qtviz.backends.webengine._figure import build + + node = _s() * qv.Inset(_zoom(), rect=(0.5, 0.5, 0.4, 0.4), label="zoom") + with pytest.warns(qv.errors.QtvizWarning, match="inset axes are not supported"): + fig, source_ids = build(node, qv.Theme.light()) + assert len(source_ids) == 1 # the parent scatter only; no inset traces + + +# ── I3: insets are panes ───────────────────────────────────────────────────── +def _inset_view(backend, qtbot): + view = qv.View(_s().opts(title="Overview") + * qv.Inset(_zoom(), rect=(0.55, 0.55, 0.4, 0.4), label="zoom"), + backend=backend) + qtbot.addWidget(view) + return view + + +@pytest.mark.tier2 +@pytest.mark.parametrize("name", ["pyqtgraph", "matplotlib"]) +def test_inset_pane_full_surface(name, qtbot): + view = _inset_view(name, qtbot) + assert [p.label for p in view.panes] == ["0", "zoom"] + pane = view.pane("zoom") + pane.set_range(x=(1.0, 3.0)) + assert pane.capture().x_range == pytest.approx((1.0, 3.0), rel=1e-3) + assert len(pane.elements) == 1 # the zoom curve, not the parent scatter + assert len(view.pane("0").elements) == 1 # the parent scatter, not the inset + + +@pytest.mark.tier2 +def test_inset_window_survives_backend_switch(qtbot): + view = _inset_view("pyqtgraph", qtbot) + view.pane("zoom").set_range(x=(1.0, 3.0), y=(0.0, 9.0)) + view.set_backend("matplotlib") + st = view.handle.capture_state() + assert st.get("zoom").x_range == pytest.approx((1.0, 3.0), rel=1e-3) + assert st.get("zoom").y_range == pytest.approx((0.0, 9.0), rel=1e-3) + view.set_backend("pyqtgraph") # and back + assert view.pane("zoom").capture().x_range == pytest.approx( + (1.0, 3.0), rel=1e-3) + + +@pytest.mark.tier2 +@pytest.mark.parametrize("name", ["pyqtgraph", "matplotlib"]) +def test_inset_events_carry_the_inset_pane(name, qtbot): + view = _inset_view(name, qtbot) + got: list = [] + view.on(qv.RangeEvent, got.append, throttle_ms=0, pane="zoom") + view.pane("0").set_range(x=(0.0, 8.0)) # parent zoom — filtered out + view.pane("zoom").set_range(x=(2.0, 3.0)) + assert got and all(e.pane == "zoom" for e in got) + assert got[-1].source_id == "zoom" # surface event: label as source + + +@pytest.mark.tier2 +@pytest.mark.parametrize("name", ["pyqtgraph", "matplotlib"]) +def test_inset_pane_export(name, qtbot, tmp_path): + view = _inset_view(name, qtbot) + view.resize(640, 480) + out = view.pane("zoom").export("png", tmp_path / "zoom.png") + assert out.exists() and out.stat().st_size > 0 + + +# ── I4: the static zoom indicator ──────────────────────────────────────────── +def _item_count(view, backend): + if backend == "pyqtgraph": + return len(view.pane("0").native.items) + ax = view.pane("0").native + return len(ax.lines) + len(ax.patches) + len(ax.collections) + + +@pytest.mark.tier2 +@pytest.mark.parametrize("name", ["pyqtgraph", "matplotlib"]) +def test_indicator_draws_a_parent_side_rect(name, qtbot): + def build(indicate): + v = qv.View(_s() * qv.Inset(_zoom(), rect=(0.55, 0.55, 0.4, 0.4), + label="zoom", indicate=indicate), + backend=name) + qtbot.addWidget(v) + return v + + plain = _item_count(build(False), name) + marked = _item_count(build(True), name) + assert marked > plain # the synthesized Rect landed on the PARENT surface + + +@pytest.mark.tier2 +def test_indicator_without_lims_warns_and_skips(qtbot): + undeclared = qv.Curve(D, x="x", y="y") # no lims on the child surface + with pytest.warns(qv.errors.QtvizWarning, match="declared x AND y lims"): + view = qv.View(_s() * qv.Inset(undeclared, rect=(0.5, 0.5, 0.4, 0.4), + label="zoom", indicate=True), + backend="pyqtgraph") + qtbot.addWidget(view) diff --git a/tools/capture_screenshots.py b/tools/capture_screenshots.py index 6bc2efd..dc292f5 100644 --- a/tools/capture_screenshots.py +++ b/tools/capture_screenshots.py @@ -67,6 +67,7 @@ "35_everyday_figures": {"size": (1500, 1040)}, "36_mosaic_layout": {"size": (1100, 620)}, "37_named_panes": {"size": (1100, 620)}, + "38_inset_zoom": {"size": (950, 560)}, "dashboard_native": {"size": (1100, 700)}, }