Skip to content

Implement per-axis pan and zoom configuration#117

Merged
Alek99 merged 8 commits into
mainfrom
agent/add-axis-scoped-zoom-examples
Jul 21, 2026
Merged

Implement per-axis pan and zoom configuration#117
Alek99 merged 8 commits into
mainfrom
agent/add-axis-scoped-zoom-examples

Conversation

@Alek99

@Alek99 Alek99 commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

  • implement the full flat pan/zoom configuration contract across Python, the wire payload, the browser client, notebooks, and Reflex
  • add default_drag_action, exact-ID pan_axes/zoom_axes/reset_axes, per-axis zoom_limits, and wheel/box/button/double-click source switches
  • replace the scalar browser viewport with canonical per-axis ranges, including independent secondary axes such as y2
  • apply one clamped mutation path to pan, wheel zoom, box zoom, toolbar zoom, reset, linked ranges, and programmatic updates
  • emit semantic view events with ranges, source, axes, phase, and interaction_id; remove the public view_change behavior flag and gate Python transport on callback presence
  • preserve selection when resetting the view and prevent zoom-out beyond the original window by default with (1.0, None)
  • contain pan-locked axes: an axis zoom_axes can navigate but pan_axes cannot never leaves its home window (cursor-anchored zoom embeds a translation, so exclusion without containment would still let zoom pan the axis); zoomed in, drag still slides it inside home
  • add the complete design document plus x-only histogram, default box-zoom histogram, dual-axis, and expanded bar-chart examples
  • rebuild the shipped widget, standalone clients, and generated Reflex gallery assets

Why

The previous viewport model assumed one x and one y range. That made source-specific behavior inconsistent and could not represent charts where y must remain fixed while y2 pans or zooms independently. Zoom enablement also did not express limits such as “never zoom out past the original window,” and event transport depended on a redundant public flag.

This change makes capabilities, drag choice, exact axis policy, input source, range limits, reset, linking, and event transport independent while keeping omitted configuration backwards compatible.

Developer impact

A normal x/y chart still pans and zooms both axes. Every zoom-enabled axis now defaults to a minimum magnification of 1.0, so users cannot zoom out past its home span. Applications can opt out with zoom_limits=(None, None) or configure limits by exact axis ID.

Multi-axis charts can use policies such as:

xy.interaction_config(
    default_drag_action="zoom",
    pan_axes=("x", "y2"),
    zoom_axes=("x", "y2"),
    zoom_limits={"x": (1.0, None), "y2": (0.5, 32.0)},
    reset_axes=("x", "y2"),
)

Excluding an axis from pan_axes while zoom can still navigate it contains the axis: its window can never extend past its home extents on any mutation path, and a zoomed-in view can still be dragged within them (the drag pins flush at the home edge). At home magnification a contained axis cannot move at all. bounds= remains the explicit hard envelope for positions beyond home, and navigation=False charts follow linked or programmatic updates freely.

Touch pinch and a dedicated keyboard viewport-navigation mode remain explicitly deferred; existing keyboard point exploration is preserved.

Validation

  • .venv/bin/pytest -q — 2,078 passed, 5 skipped, including headless-Chromium probes locking the wheel-gesture terminal end event and the pan-lock containment invariant (both verified fail-first against the pre-change client)
  • .venv/bin/ruff check and ruff format --check over the repo
  • .venv/bin/python scripts/render_smoke_nonumpy.py
  • .venv/bin/python scripts/reflex_lifecycle_smoke.py — 23 charts × loads/remounts/reloads/reveals
  • node js/build.mjs
  • reflex compile --dry in examples/reflex
  • production example app verified in-browser:
    • x and y2 zoom independently while reversed primary y remains fixed
    • zoom-out clamps at the 100% home window
    • histogram mounts with default_drag_action="zoom"

@codspeed-hq

codspeed-hq Bot commented Jul 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 94 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing agent/add-axis-scoped-zoom-examples (dccf7c8) with main (09eb9f0)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@Alek99 Alek99 changed the title Add axis-scoped zoom controls and chart examples Implement per-axis pan and zoom configuration Jul 21, 2026
@Alek99
Alek99 marked this pull request as ready for review July 21, 2026 06:08
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds per-axis pan and zoom configuration across the chart stack. The main changes are:

  • Per-axis pan, zoom, reset, and zoom-limit policies.
  • Canonical per-axis ranges and semantic view events.
  • Unified clamping for gesture, toolbar, linked, and programmatic updates.
  • Updated Python, notebook, Reflex, browser, documentation, examples, and generated assets.
  • Deterministic coverage for the wheel-gesture terminal event race.

Confidence Score: 5/5

This looks safe to merge.

The wheel timer is cancelled before the queued frame can race the old deadline. The browser test checks for one final event carrying the committed ranges.

No blocking issue was found in the updated path.

T-Rex T-Rex Logs

What T-Rex did

  • Validated that the test run completed with exit code 0 and that the exact pytest command and working directory are captured in the after-state log.
  • Validated the probe-values log shows all listed booleans true for chainYContained, chainXEscaped, programmaticYContained, dragMovedLockedAxis, dragClampedAtHomeEdge, freeXEscaped, and freeYStayedHome.
  • Compared the before and after visual artifacts and confirmed the chart's render region changed after wheel zoom and drag interaction.
  • Verified that the artifact set includes the videos, poster frames, and logs that document the test run, probe results, and UI-state changes for reviewer inspection.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
js/src/53_interaction.js Adds per-axis navigation policies and cancels the wheel end timer when a new delta is queued.
tests/test_wheel_gesture_end.py Adds deterministic browser coverage for wheel deltas arriving immediately before the gesture deadline.

Reviews (7): Last reviewed commit: "Merge main: vectorized style channels (#..." | Re-trigger Greptile

Comment thread js/src/53_interaction.js
Rebasing onto main pulled in #122, which moved engineering docs to spec/
and added spec/api/interaction.md — the declared authority on interaction
switches and event payloads — describing the pre-#117 contract. That now
contradicts the shipped, tested per-axis implementation. Bring the spec and
public docs back in line with the code (docs only, no behavior change):

- interaction.md: drop the removed `view_change` switch; add the source
  toggles (wheel_zoom/box_zoom/zoom_buttons/double_click_reset) and a new
  §2.1 for the non-boolean axis-policy/zoom-limit keys; rewrite the event
  surface to {ranges, source, axes, phase, interaction_id} with x0/x1/y0/y1
  aliases; fix the source vocabulary, the double-click gate (navigation +
  double_click_reset, not zoom), and the linked-view semantics (a
  navigation-locked receiver still follows its group).
- wire-protocol.md, chart-grammar.md, reflex-shaped-api.md,
  events-and-callbacks.md, chart-kind-contract.md: the same view_change ->
  ranges and switch-removal corrections at their altitude.
@Alek99
Alek99 force-pushed the agent/add-axis-scoped-zoom-examples branch from 9ee3765 to 3d9b8f9 Compare July 21, 2026 06:19
Comment thread js/src/53_interaction.js
@Alek99
Alek99 force-pushed the agent/add-axis-scoped-zoom-examples branch from f775ae0 to 9dd2457 Compare July 21, 2026 06:45
Greptile P1 (verified): a wheel event landing just before the 90 ms
end-of-gesture timer let "end" fire — and the apply rAF, queued earlier,
then overwrite it in the view-event coalescer — while the final delta was
still pending, so a continuous wheel gesture could finish without an "end"
carrying the final committed range (spec §11.3/§15). _queueWheelZoom now
clears the end timer whenever it queues a delta; only the applying frame
re-arms it.

CI, browser lifecycle smoke: the new x-only-zoom latency histogram is
lognormal, so at wide canvases the center-sampled lit-pixel probe landed
entirely in the near-empty tail and read 0. Give the example a home
domain of (0, 250) framing the bulk — which also demonstrates domain-as-
home from the pan/zoom spec; the tail stays reachable by panning and the
default zoom limit clamps zoom-out at this window. Also chunk the smoke's
iframe shell to 16 charts per mount: the five examples this PR adds took
the static fleet to 21 concurrent WebGL contexts, past Chrome's 16-context
LRU cap, which evicted the earliest slots' contexts mid-probe.

CI, benchmark methodology: bench_interaction still passed the removed
view_change chart kwarg; drop it — xy:view_change DOM events are always
emitted and the harness listens on the chart root.

Regenerate the example chart assets, which also picks up the #118
density-flicker fixes merged during the rebase (the committed assets
still embedded the pre-rebase client).
@Alek99
Alek99 force-pushed the agent/add-axis-scoped-zoom-examples branch from 9dd2457 to b642cec Compare July 21, 2026 06:54

@masenf masenf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the example/reflex app needs to be rewritten, it's unmaintainable in its current state. i think we can just get rid of the static html generation and just define the reflex charts as regular python functions from build_charts.py (and use getsource() so the displayed code and chart doesn't differ).

as it is now, the main changes to the examples are in the source string, rather than the code that builds the html 🥲

i'll do that in a second PR based on this branch: #124

@masenf masenf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing i still think is a little weird is you can set an axis to zoom but not pan. then once the chart is zoomed, you cannot pan the zoomed view, but you can achieve the same effect by zooming out, then zooming in on a different part of the graph. idk how exactly to solve it yet, but it feels weird.

@Alek99

Alek99 commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@greptile

Alek99 added 2 commits July 21, 2026 10:20
The end-timer cancellation moved to the queue path in b642cec, but nothing
covered it. Because view events coalesce into one animation frame, a timer
surviving into the next delta does not just fire early: the apply frame queued
behind it overwrites the pending "end", so the gesture delivers no terminal
event at all and consumers that commit on "end" wait on a stale "update".

Drive the interleave in the real client behind a deterministic clock and frame
queue, placing a delta 1 ms before the deadline, and assert the consumer-visible
`xy:view_change` stream still ends in exactly one "end" carrying the committed
range. Verified the test fails against the pre-fix client (endCount == 0).

Also null the timer handle alongside clearTimeout, matching how 50_chartview.js
pairs them.
Excluding an axis from pan_axes while zoom could still navigate it was a
hollow guarantee: cursor-anchored zoom is a scaling composed with a
translation (dcenter = span * (anchor - 1/2) * (1 - f)), so a zoom-in/out
chain at two cursor positions panned the "locked" axis exactly, box zoom
translated it in one drag, and the default zoom_limits only cap span, not
position.

Exclusion now means containment. The shared clamp tightens the positional
envelope (axis bounds intersected with the home window) for any axis zoom
can navigate but pan cannot, so its window never extends past home on any
mutation path -- pan, wheel, box, and button zoom, linked ranges, and
programmatic updates. Inside the envelope the axis stays live: plain drag
includes contained axes, so a zoomed-in window slides within home and pins
flush at the extent, while at home magnification it cannot move at all.
With navigation off nothing is contained, keeping read-only follower
charts free.

Spec: interaction.md gains the containment invariant (§2.2); the design
doc folds it through the pipeline, precedence, wheel/box, zoom-limit,
programmatic, and linked-charts sections, and notes that action-specific
axis policy is only independent because of this clamp.

The containment probe drives the real client in headless Chromium with
pan_axes=("x",): the wheel-chain escape stays inside home on y while
free x genuinely escapes, programmatic sets slide back, a zoomed-in drag
moves y within home and pins at the edge, and pan_drag end events report
the contained axis. Verified fail-first against the pre-change client.
@Alek99

Alek99 commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@masenf on both review comments:

Examples rewrite — agreed, and #124 looks like the right shape. I've kept this branch additive-only (no force pushes) since you branched it.

Zoom-without-pan — your instinct was right, and it's worse than the two-step route: cursor-anchored zoom is a scaling composed with a translation (Δcenter = span · (anchor − ½) · (1 − f)), so a zoom-in at one cursor position followed by a zoom-out at another is an exact pan of the "locked" axis — spans cancel bit-for-bit, only the translation survives. Box zoom could translate it in a single drag. The default zoom_limits=(1.0, None) caps span, not position, so it never stopped any of this.

560f51f resolves it by making exclusion from pan_axes mean containment rather than gesture removal:

  • a pan-locked axis's window can never extend past its home extents, on any mutation path (wheel/box/button zoom, drag, linked, programmatic — it's one clamp in the shared pipeline);
  • zoomed in, plain drag now works on the locked axis — it slides within home and pins flush at the edge — so the "zoom out then zoom in somewhere else" workaround you described becomes a plain drag;
  • at home magnification the window fills the envelope and can't move at all, which is the behavior you already liked;
  • navigation=False charts aren't contained, so read-only followers still track a controller freely, and bounds= remains the explicit envelope when you want positions past home.

Spec: interaction.md §2.2 states the invariant; the design doc threads it through the pipeline/precedence/zoom-limit/linked sections. tests/test_pan_lock_containment.py drives the real client in headless Chromium and was verified fail-first (the wheel-chain escape walks the locked axis out of home on the pre-change client).

One adjacent pre-existing quirk worth a follow-up: the wheel anchor divides by the canvas rect while pan uses the plot rect, so the zoom focal point drifts from the cursor by the axis-margin fraction. Identical on the base commit, so I left it out of this PR.

)

Semantic resolutions:
- widget.py: keep this branch's _configure_transport (callback-gated view
  transport) and adopt #98's expanded append() style-channel signature; the
  transport hook now runs on the expanded message.
- render_smoke_nonumpy.py: the probe string keeps this branch's xonly flag
  and gains #98's vstyle via baseWithStyle.
- Bundles and example assets regenerated from the merged js/src (contains
  containment + style channels + context release); the plotly comparison
  baseline stays at main's exact bytes.
@Alek99
Alek99 merged commit 79b0a3f into main Jul 21, 2026
25 checks passed
masenf added a commit that referenced this pull request Jul 21, 2026
…anchor

_zoomAxisRange's zoom-out guard snapped an axis to its home *range* once it
reached home span, discarding the translation from a cursor-anchored zoom
chain. A zoom-in-high then zoom-out-low pans a free (pan-enabled) axis; the
snap yanked it back to home. Cap the span at home while preserving the anchor
instead, matching the spec (zoom-out stops at home span, §7.5). Positional
containment remains the mechanism that pins locked axes to home.

Also fix the zoom-precision probe's finite check: view.view now nests a
`ranges` object (per-axis pan/zoom, #117), so Object.values(current) included a
non-number. Check the numeric view coordinates directly.
masenf added a commit that referenced this pull request Jul 21, 2026
* Fix scatter precision after repeated box zoom

* Fix anisotropic scatter zoom-out collapse

* Remove unrelated scatter precision workaround

* fix(client): anchor hover tooltip to its point across view changes

The tooltip stored only the cursor's screen position, so any view change
that happened without a pointermove — modebar Reset View, dblclick home,
wheel zoom, linked views — left it floating at a stale location showing
values for a point that was no longer there.

Anchor it in data space instead (matplotlib's data-coordinate-annotation
contract): record the hovered point's coordinates at pick time, reproject
them on every draw so the tooltip rides its point through pans, zooms,
and reset animations, and clip it when the anchor leaves the plot rect
rather than clamping to an edge that misrepresents where the point is.
The kernel's exact pick reply sharpens the f32-decoded anchor to f64
(§16), and every hide path clears the anchor so a later draw cannot
resurrect a dismissed tooltip.

* spec: document zoom limits, tooltip data-space anchoring, and offset-encoded linear mapping

interaction.md §5 gains the zoom-limit rules this branch introduced (the
§16 precision floor on zoom-in and the per-axis home-span stop on
zoom-out); a new §7 specifies the tooltip's data-coordinate anchoring
contract. design-dossier.md §16 records that linear axes stay
offset-encoded through the vertex transform (only log axes decode).
renderer-architecture.md module inventory resynced with the code.

* fix(client): cap zoom-out at home span without discarding the cursor anchor

_zoomAxisRange's zoom-out guard snapped an axis to its home *range* once it
reached home span, discarding the translation from a cursor-anchored zoom
chain. A zoom-in-high then zoom-out-low pans a free (pan-enabled) axis; the
snap yanked it back to home. Cap the span at home while preserving the anchor
instead, matching the spec (zoom-out stops at home span, §7.5). Positional
containment remains the mechanism that pins locked axes to home.

Also fix the zoom-precision probe's finite check: view.view now nests a
`ranges` object (per-axis pan/zoom, #117), so Object.values(current) included a
non-number. Check the numeric view coordinates directly.

* fix(export): tolerate Chromium profile-dir cleanup races on session close

Chromium helper processes can outlive the terminated main process and
write into the temp profile while rmtree walks it, failing CI teardown
with ENOTEMPTY. Retry the cleanup, then abandon leftovers.

* Revert "fix(export): tolerate Chromium profile-dir cleanup races on session close"

This reverts commit e9552a6.

* fix(export): shut Chromium down via CDP Browser.close before profile cleanup

SIGTERM killed the browser process out from under its helper processes,
which kept flushing into the profile's Default/ while TemporaryDirectory
cleanup walked it, failing CI teardown with ENOTEMPTY. Browser.close asks
Chromium for an orderly exit — it flushes profile state and reaps its
helpers before the main process exits — so waiting on the process leaves
the profile quiescent. Also drop the crashpad handler, a detached process
that writes into the profile on its own schedule. Signals remain only as
escalation if the browser fails to exit.

---------

Co-authored-by: Masen Furer <m_github@0x26.net>
Co-authored-by: Masen Furer <masen@reflex.dev>
Alek99 added a commit that referenced this pull request Jul 22, 2026
…c control, axis gestures, hover payload) (#129)

* Add unified view-state layer design spec

One serializable view-state object with a single clamped mutation path,
specified as the shared foundation for view history (#110), custom
tooltip renderers (#111), axis-scoped gestures (#120), and out-of-band
programmatic zoom/pan/select via reflex_xy (#121). Builds on the
per-axis ranges and semantic view events introduced in PR #117.

* Address view-state spec review: durability and multi-client contract gaps

- Rows-selections are non-durable and non-undoable: excluded from the
  state schema, the history stack, and round-trip guarantees;
  view_state() reports an opaque marker instead of index sets.
- Remove drag_action from the durable document: the modebar tool is UI
  mode the kernel cannot observe, not view state.
- Drop Python-side view_back()/view_forward() and the back/forward wire
  ops: history stacks are client-local, so broadcast navigation cannot
  converge; Back/Forward live only in the modebar and the static JS
  handle, and view_nav carries only reset.
- Key cursor.data by exact axis ID and add per-point x_axis/y_axis
  bindings so tooltips are unambiguous with secondary axes.
- Ship the hover payload on a new on_hover prop; on_point_hover(row)
  keeps its bare-row contract (nesting row was not compatibility).
- Record that set_view's figure-lock hold is bounded, mirroring append's
  handler-safety contract.

* Implement the unified view-state layer (all four phases)

One canonical durable-state document (v:1 ranges + geometric selection)
behind a single apply-a-state-patch implementation in the client
(js/src/57_viewstate.js), per spec/design/view-state.md:

- #121 programmatic control: state_patch / view_nav / selection_rows
  kernel messages; Chart/FigureWidget set_view, reset_view, select,
  clear_selection, view_state (event-fed kernel cache, no round-trip);
  reflex_xy.set_view et al. pushed room-wide like .append.
- #110 history: client-local 64-snapshot stack coalesced per gesture by
  interaction_id, modebar Back/Forward, reset-as-navigation,
  interaction_config(history=...); root.xy handle exposes
  applyState/state/back/forward on every mount.
- #120 axis-band gestures: per-axis bands with resize cursors; wheel,
  drag-along (pan), and drag-across (span box-zoom) scoped to the
  hovered axis through the existing clamp pipeline.
- #111 structured hover: additive cursor/points payload on xy:hover
  (cursor.data keyed by exact axis ID), new reflex on_hover prop, and
  the xy.tooltip(render=...) mount positioned by the client.

End-phase view events now always ship to feed view_state(); update
phases stay listener-gated. Rows-selections are non-durable by design.
Chart's stored select switch moves to a private name so the kwarg no
longer shadows the new Chart.select() method.

Specs updated as shipped authorities (interaction.md, wire-protocol.md;
view-state.md flipped to implemented with recorded divergences). Tests:
21 kernel-side, 9 headless-Chromium probes, 6 reflex push tests.

* Address view-state review: tooltip payload, mask clearing, retarget, index validation

- XYChart.jsx: clone element children with the live §7.1 hover payload
  ({active, cursor, points, exact}) driven by hover state, so
  xy.tooltip(render=...) renders live content client-side instead of a
  frozen slot; DOM-tag children stay uncloned so no non-DOM attributes
  leak, and the state churn is skipped when no children are mounted.
- 57_viewstate.js: a selection_rows document replaces the whole
  selection — deactivate every existing mask before applying, so traces
  omitted from the document clear instead of keeping stale highlights
  while total reports only the new rows.
- 53_interaction.js: a write landing mid-animation coalesces into the
  in-flight navigation — no intermediate history snapshot — and the
  settle event carries the latest writer's source/phase/interaction id
  by storing them on the animation instead of the first call's closure.
- _figure.py: validate canonical row indices (bounds, integrality,
  dtype) before the uint32 encoding, so -1 can no longer ship as
  4294967295; deduplicate and report total from validated unique rows.

Specs updated (view-state.md §4/§5.1, wire-protocol.md selection_rows).
Tests: index-validation cases, two headless-Chromium probes (mask
clearing across two traces, retarget coalescing + event metadata), and
a JSX contract test for the payload injection.

* Axis-band cursor advertises capability: resize only where zoom works

A pan-only axis band (in pan_axes but not zoom_axes) showed the same
resize arrow as a zoomable one, promising a zoom the policy ignores.
The band cursor now reflects the axis's actual capability: ew/ns-resize
only when the axis can zoom, a grab hand when it can only pan, and
grabbing while a pan drag is active (restored on release).

Specs updated (view-state.md §6, interaction.md gesture table); the
axis-band policy probe now asserts all three cursor states, with the
no-band-for-excluded-axis case split into its own probe.

* Fix stranded hover state when the canvas pointerleave is missed

Retest regression on /hover: hover a point, move into the page header,
and the custom tooltip could remain visible with the on_hover payload
stuck at active: true. Canvas pointerleave was the ONLY thing clearing
pointer hover, and browsers skip boundary events when the element under
a stationary cursor changes (page scroll, overlay/hit-test churn) — the
readout then never self-heals while the pointer roams the page.

Factor the pointerleave cleanup into _pointerHoverExit and add a
document-level backstop: while a pointer-owned readout is live, any
pointerover whose target left the chart root runs the same exit path
(tooltip hidden, state cleared, xy:leave with active: false). Keyboard
readouts are exempt — keyboard traversal also stores an XY for exact
replies, but its readout must survive mouse movement elsewhere; Escape
owns dismissal. Document listeners ride the existing _listen registry,
so destroy() still removes them.

New headless probe covers the full contract: pointer hover set, inside
pointerover kept, outside pointerover cleared + leave dispatched,
keyboard readout kept. Verified live against the stranded state in the
PR test lab. Specs updated (interaction.md §3 xy:leave, view-state.md
§7.1 active-flag guarantee).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants