From 6cf133dd15e4d37feca01f559135cdf50b9b497d Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:12:46 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(2.0):=20[D152]=20Inset=20node=20?= =?UTF-8?q?=E2=80=94=20a=20child=20surface=20as=20a=20structural=20element?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qv.Inset(child, rect=, label=, indicate=) composes into an overlay like an annotation (parent * Inset(...)). Element gains the declared STRUCTURAL_CHILD marker ([D124] discipline): resolve_node/node_is_lazy recurse into the child, _elements_of yields through it (negotiation's intersect-first rule covers inset contents), and series_index_map treats structural elements as chrome. Depth-1 and rect validation at construction. Freeze triple: FROZEN_2_0 + api.md + CHANGELOG. Design: design/inset-axes.md Part II (I1). --- CHANGELOG.md | 15 ++ design/inset-axes.md | 336 +++++++++++++++++++++++++++++++++ docs/api.md | 2 + src/qtviz/__init__.py | 2 + src/qtviz/core/compose.py | 6 +- src/qtviz/core/element.py | 5 + src/qtviz/data/pipeline.py | 7 + src/qtviz/elements/__init__.py | 2 + src/qtviz/elements/inset.py | 65 +++++++ tests/qtviz/test_api_freeze.py | 1 + tests/qtviz/test_inset.py | 103 ++++++++++ 11 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 design/inset-axes.md create mode 100644 src/qtviz/elements/inset.py create mode 100644 tests/qtviz/test_inset.py 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/design/inset-axes.md b/design/inset-axes.md new file mode 100644 index 0000000..1d4b074 --- /dev/null +++ b/design/inset-axes.md @@ -0,0 +1,336 @@ +# 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 + +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/src/qtviz/__init__.py b/src/qtviz/__init__.py index f99a17d..acd9675 100644 --- a/src/qtviz/__init__.py +++ b/src/qtviz/__init__.py @@ -69,6 +69,7 @@ Span, Spread, Stem, + Inset, Streamlines, Text, Violin, @@ -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/core/compose.py b/src/qtviz/core/compose.py index 8a4b4c1..6f11855 100644 --- a/src/qtviz/core/compose.py +++ b/src/qtviz/core/compose.py @@ -567,7 +567,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 +580,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) 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..bf8ac95 100644 --- a/src/qtviz/elements/__init__.py +++ b/src/qtviz/elements/__init__.py @@ -13,6 +13,7 @@ from .histogram import Histogram from .image import Image from .mesh import Mesh +from .inset import Inset from .pie import Pie from .quiver import Quiver from .raw_figure import RawFigure @@ -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..a703207 --- /dev/null +++ b/src/qtviz/elements/inset.py @@ -0,0 +1,65 @@ +"""`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 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_inset.py b/tests/qtviz/test_inset.py new file mode 100644 index 0000000..0dc17bf --- /dev/null +++ b/tests/qtviz/test_inset.py @@ -0,0 +1,103 @@ +"""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 From 59da540de2e22c07e79203367b3c74aa3bae9cfc Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:21:24 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(2.0):=20[D152]=20inset=20renderers=20?= =?UTF-8?q?=E2=80=94=20mpl=20native,=20pg=20child=20PlotItem,=20webengine?= =?UTF-8?q?=20warn-skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interception is the surface loop (where the y2 branch lives), not the renderer registry — supports()/honored_options gain a structural clause. matplotlib: ax.inset_axes + _render_cell re-entry (theming, surface apply, events, PaneBus stamping all inherited). pyqtgraph: _render_cell splits into _surface_target/_populate_plot shared by grid cells and insets; a child PlotItem parents onto the parent plot, placed from rect (mpl bottom-origin semantics, Qt y-down conversion) on the parent ViewBox's sigResized (spiked offscreen). webengine: the figure builder warns and skips insets until I5; supports() accepts so negotiation proceeds. Labels thread as a deque in traversal order, matching flat_pane_labels' depth-first walk (which now includes insets). negotiate/auto_negotiate recurse into structural children — same-surface ⇒ same-backend enforced. mpl [D146] leader lookup gains a child→surface index map (insets shift the surfaces list). Element census 28→29 (CLAUDE.md + vocabulary test). Design: design/inset-axes.md Part II (I2). --- CLAUDE.md | 2 +- src/qtviz/__init__.py | 2 +- src/qtviz/backends/matplotlib/render.py | 49 ++++++++++---- src/qtviz/backends/pyqtgraph/render.py | 88 ++++++++++++++++++++----- src/qtviz/backends/webengine/_figure.py | 13 ++++ src/qtviz/backends/webengine/render.py | 5 ++ src/qtviz/core/compose.py | 27 +++++++- src/qtviz/elements/__init__.py | 2 +- tests/qtviz/test_channel_vocabulary.py | 4 +- tests/qtviz/test_inset.py | 57 ++++++++++++++++ 10 files changed, 215 insertions(+), 34 deletions(-) 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/src/qtviz/__init__.py b/src/qtviz/__init__.py index acd9675..7387fe9 100644 --- a/src/qtviz/__init__.py +++ b/src/qtviz/__init__.py @@ -58,6 +58,7 @@ Histogram, HLine, Image, + Inset, Mesh, Pie, Polygon, @@ -69,7 +70,6 @@ Span, Spread, Stem, - Inset, Streamlines, Text, Violin, diff --git a/src/qtviz/backends/matplotlib/render.py b/src/qtviz/backends/matplotlib/render.py index f2e5ec6..98273ae 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,12 @@ 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) + 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..37e1a78 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,26 @@ 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) + 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 +425,35 @@ 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) + 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 6f11855..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)] @@ -629,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 @@ -656,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/elements/__init__.py b/src/qtviz/elements/__init__.py index bf8ac95..4da5004 100644 --- a/src/qtviz/elements/__init__.py +++ b/src/qtviz/elements/__init__.py @@ -12,8 +12,8 @@ from .heatmap import Heatmap from .histogram import Histogram from .image import Image -from .mesh import Mesh from .inset import Inset +from .mesh import Mesh from .pie import Pie from .quiver import Quiver from .raw_figure import RawFigure 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_inset.py b/tests/qtviz/test_inset.py index 0dc17bf..7b0b91c 100644 --- a/tests/qtviz/test_inset.py +++ b/tests/qtviz/test_inset.py @@ -101,3 +101,60 @@ def test_inset_is_chrome_in_series_indexing(): 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 From 44894c875460374c9d36ed9978e6eaffcb6b75d4 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:21:59 -0700 Subject: [PATCH 3/6] =?UTF-8?q?test(2.0):=20[D153]=20insets=20are=20panes?= =?UTF-8?q?=20=E2=80=94=20state/events/export=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No production changes needed beyond I2: flat_pane_labels already walks insets and the backends append inset surfaces in depth-first order, so the pane protocol carries them. Pins: pane lists ['0','zoom']; per-pane element scoping; the inset window surviving pg⇄mpl backend switches via label-keyed LayoutState; RangeEvent(pane='zoom') + view.on(pane=) scoping; per-pane export of just the inset. --- tests/qtviz/test_inset.py | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/qtviz/test_inset.py b/tests/qtviz/test_inset.py index 7b0b91c..6e3429f 100644 --- a/tests/qtviz/test_inset.py +++ b/tests/qtviz/test_inset.py @@ -158,3 +158,58 @@ def test_webengine_figure_skips_inset_with_warning(): 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 From d0b32e6fbbcfa60115a10f816e1af98a76b82f54 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:23:48 -0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(2.0):=20[D154]=20static=20zoom=20indic?= =?UTF-8?q?ator=20=E2=80=94=20Inset.indicator()=20synthesizes=20a=20Rect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit indicate=True draws the parent-side rectangle at the child surface's declared x/y lims — the synthesized wave-1 Rect annotation rendered through the parent's element path, so it lowers everywhere with zero new drawing code. Both lims required; missing lims warn-and-skip (the live follow-the-pan indicator is a gated follow-on, design/inset-axes.md I4b). Design: design/inset-axes.md Part II (I4). --- src/qtviz/backends/matplotlib/render.py | 4 +++ src/qtviz/backends/pyqtgraph/render.py | 4 +++ src/qtviz/elements/inset.py | 26 +++++++++++++++++++ tests/qtviz/test_inset.py | 33 +++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/qtviz/backends/matplotlib/render.py b/src/qtviz/backends/matplotlib/render.py index 98273ae..f587333 100644 --- a/src/qtviz/backends/matplotlib/render.py +++ b/src/qtviz/backends/matplotlib/render.py @@ -368,6 +368,10 @@ def _render_cell(self, node, ax, theme, bus, surfaces, natives, 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, diff --git a/src/qtviz/backends/pyqtgraph/render.py b/src/qtviz/backends/pyqtgraph/render.py index 37e1a78..fa9fb17 100644 --- a/src/qtviz/backends/pyqtgraph/render.py +++ b/src/qtviz/backends/pyqtgraph/render.py @@ -403,6 +403,10 @@ def _populate_plot(self, node, plot, vb, surf, x_scale, y_scale, bus, theme, 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, diff --git a/src/qtviz/elements/inset.py b/src/qtviz/elements/inset.py index a703207..73514c0 100644 --- a/src/qtviz/elements/inset.py +++ b/src/qtviz/elements/inset.py @@ -63,3 +63,29 @@ def __init__(self, child, *, rect: tuple[float, float, float, float], 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_inset.py b/tests/qtviz/test_inset.py index 6e3429f..57cd88b 100644 --- a/tests/qtviz/test_inset.py +++ b/tests/qtviz/test_inset.py @@ -213,3 +213,36 @@ def test_inset_pane_export(name, qtbot, tmp_path): 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) From aa536f70d04c86f3020e75322e3c3d25e8f62ce1 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:26:38 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(2.0):=20[D152]=20pg=20inset=20panel=20i?= =?UTF-8?q?s=20opaque=20=E2=80=94=20autoFillBackground=20+=20themed=20pale?= =?UTF-8?q?tte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parent's curves/grid showed through the inset's margins (ViewBox background covers only the data area). autoFillBackground over the whole PlotItem rect matches matplotlib's opaque inset (spiked visually). --- src/qtviz/backends/pyqtgraph/render.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/qtviz/backends/pyqtgraph/render.py b/src/qtviz/backends/pyqtgraph/render.py index fa9fb17..25d74bb 100644 --- a/src/qtviz/backends/pyqtgraph/render.py +++ b/src/qtviz/backends/pyqtgraph/render.py @@ -443,6 +443,16 @@ def _render_inset(self, inset, parent_plot, theme, raw_bus, plots, natives, 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 From 0072531929cfec1424c18a8603d0df623330e742 Mon Sep 17 00:00:00 2001 From: Mark Jajeh Date: Wed, 5 Aug 2026 14:28:20 -0700 Subject: [PATCH 6/6] docs(2.0): [D152] inset-zoom example + gallery screenshot; design status examples/38_inset_zoom.py: an overview curve with a labeled zoom inset and the indicator rectangle; main() demos the pane-scoped RangeEvent subscription. Wired into the capture tool, the gallery, and the example-mains regression list. design/inset-axes.md Part II gains the I1-I4-shipped status note (I5 webengine domain axes stays gated). --- design/inset-axes.md | 6 +++ docs/gallery.md | 9 ++++ docs/images/examples/38_inset_zoom.png | Bin 0 -> 30190 bytes examples/38_inset_zoom.py | 56 +++++++++++++++++++++++++ tests/qtviz/test_example_mains.py | 1 + tools/capture_screenshots.py | 1 + 6 files changed, 73 insertions(+) create mode 100644 docs/images/examples/38_inset_zoom.png create mode 100644 examples/38_inset_zoom.py diff --git a/design/inset-axes.md b/design/inset-axes.md index 1d4b074..30fa73d 100644 --- a/design/inset-axes.md +++ b/design/inset-axes.md @@ -161,6 +161,12 @@ holding for all-three parity; (3) `indicate` rectangle-only forever # 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 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 0000000000000000000000000000000000000000..f2242345b695449fd49efd55cbd673658fa79ecc GIT binary patch literal 30190 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 rzoilXF<O;=_SHQMQEt#C>JW;dh~w)H5>ti literal 0 HcmV?d00001 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/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/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)}, }