diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index b9c2825e..91124a26 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -262,8 +262,9 @@ def set_bins(self, value: list[int | float]): @rx.event def on_view(self, event: reflex_xy.ViewChangeEvent): # `event` is the v1 view-change envelope; `x_domain` is the reported - # [x0, x1] window (debounced by the wrapper). Store the window; the - # `detail` figure var depends on it and recomputes. + # [x0, x1] window (throttled by the wrapper, streaming during the + # gesture). Store the window; the `detail` figure var depends on it + # and recomputes. x_domain = event.get("x_domain") or [0.0, 0.0] self.view_x0 = float(x_domain[0]) self.view_x1 = float(x_domain[1]) diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md index b4d66760..87106ece 100644 --- a/python/reflex-xy/README.md +++ b/python/reflex-xy/README.md @@ -113,7 +113,8 @@ def index(): Selection JSON is capped and reports `total_count` plus `truncated`; call `reflex_xy.resolve_selection(event)` in the handler when all canonical rows -are required. Hover is latest-wins throttled, view changes are debounced, and +are required. Hover and view changes are latest-wins throttled (view changes +stream live during a gesture and always end on a `final`-phase event), and view/selection state survives dependent republishes without feedback events. The complete envelopes, limits, clear/shared-handler/viewport examples, and static-versus-live availability are documented in diff --git a/python/reflex-xy/reflex_xy/assets/XYChart.jsx b/python/reflex-xy/reflex_xy/assets/XYChart.jsx index 39293cbd..34e00c0c 100644 --- a/python/reflex-xy/reflex_xy/assets/XYChart.jsx +++ b/python/reflex-xy/reflex_xy/assets/XYChart.jsx @@ -42,7 +42,7 @@ import { ChartView, decodeFrame, renderStandalone } from "./xy_client.js"; // Opt-in console tracing: localStorage.setItem("xy_debug", "1") const DEBUG = globalThis.localStorage?.getItem?.("xy_debug") === "1"; const HOVER_THROTTLE_MS = 120; -const VIEW_DEBOUNCE_MS = 200; +const VIEW_THROTTLE_MS = 120; const dbg = (...args) => DEBUG && console.log( @@ -311,26 +311,42 @@ export function XYChart(props) { }, HOVER_THROTTLE_MS); }; - const dispatchView = (m) => { - if (!cbRef.current.onViewChange || m.source === "linked" || m.source === "republish") return; - pendingView = m; - if (viewTimer !== null) clearTimeout(viewTimer); + const emitView = (m) => { + if (destroyed || !m || !cbRef.current.onViewChange) return; + cbRef.current.onViewChange({ + version: 1, + type: "view_change", + token, + x_domain: [m.x0, m.x1], + y_domain: [m.y0, m.y1], + source: m.source, + phase: m.phase === "update" ? "update" : "final", + }); + }; + + // Leading + trailing throttle, latest-wins: the first event of a gesture + // dispatches immediately so on_view_change-driven charts track pan/zoom + // live; the trailing flush guarantees the resting "final" view lands. + const openViewWindow = () => { viewTimer = setTimeout(() => { viewTimer = null; - const latest = pendingView; - pendingView = null; - if (!destroyed && latest && cbRef.current.onViewChange) { - cbRef.current.onViewChange({ - version: 1, - type: "view_change", - token, - x_domain: [latest.x0, latest.x1], - y_domain: [latest.y0, latest.y1], - source: latest.source, - phase: "final", - }); + if (pendingView !== null) { + const latest = pendingView; + pendingView = null; + emitView(latest); + openViewWindow(); } - }, VIEW_DEBOUNCE_MS); + }, VIEW_THROTTLE_MS); + }; + + const dispatchView = (m) => { + if (!cbRef.current.onViewChange || m.source === "linked" || m.source === "republish") return; + if (viewTimer === null) { + emitView(m); + openViewWindow(); + } else { + pendingView = m; + } }; const comm = { diff --git a/python/reflex-xy/reflex_xy/events.py b/python/reflex-xy/reflex_xy/events.py index 86e6b445..2a0e5c75 100644 --- a/python/reflex-xy/reflex_xy/events.py +++ b/python/reflex-xy/reflex_xy/events.py @@ -177,7 +177,12 @@ class SelectEndEvent(TypedDict): class ViewChangeEvent(TypedDict): - """``on_view_change`` — debounced final viewport after pan/zoom. + """``on_view_change`` — throttled viewport stream during and after pan/zoom. + + Dispatches are leading+trailing throttled (latest-wins): ``update``-phase + events stream while the gesture is in progress so dependent charts track + it live, and the resting viewport always arrives as a final ``final``-phase + event. Handlers that only care about settled views can filter on ``phase``. Shape:: @@ -188,7 +193,7 @@ class ViewChangeEvent(TypedDict): "x_domain": [x0, x1], # f64 data-space window "y_domain": [y0, y1], "source": str, # gesture: pan/zoom/keyboard/... - "phase": "final", + "phase": "update" | "final", } """ @@ -198,4 +203,4 @@ class ViewChangeEvent(TypedDict): x_domain: list[float] # [x0, x1] y_domain: list[float] # [y0, y1] source: str # gesture that produced the view (pan/zoom/keyboard/...) - phase: Literal["final"] + phase: Literal["update", "final"] diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 95211424..aa091110 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -151,8 +151,8 @@ totality contract). This section records only what is specific to this host. **`view_change` does not reach the kernel here.** The wrapper intercepts the outgoing message and invokes the Reflex `on_view_change` prop directly -(`python/reflex-xy/reflex_xy/assets/XYChart.jsx:164-169`), because the -namespace registers no Python-side view callback (§5). Every other request +(`dispatchView` in `python/reflex-xy/reflex_xy/assets/XYChart.jsx`), because +the namespace registers no Python-side view callback (§5). Every other request type crosses the socket unchanged and is dispatched by the shared `handle_message`. @@ -496,8 +496,12 @@ def shared(self, event: dict): ``` View events are `{version, type: "view_change", token, x_domain, y_domain, -source, phase: "final"}`. User changes are trailing-edge debounced for 200 ms; -linked and republish sources are suppressed. Hover events are latest-wins and +source, phase: "update" | "final"}`. User changes are throttled to one +dispatch per 120 ms with a leading edge and a latest-wins trailing flush: +`update`-phase events stream while the gesture is in progress (this is what +lets an `on_view_change`-computed detail chart track a pan/zoom live), and the +resting viewport always lands as the last event with `phase: "final"`. Linked +and republish sources are suppressed. Hover events are latest-wins and throttled to one dispatch per 120 ms. For viewport synchronization: ```python diff --git a/tests/reflex_adapter/test_component.py b/tests/reflex_adapter/test_component.py index 02accf66..e04d8651 100644 --- a/tests/reflex_adapter/test_component.py +++ b/tests/reflex_adapter/test_component.py @@ -135,7 +135,7 @@ def test_semantic_event_wrapper_contracts_are_present(): assert "interaction.view_change = true" in source assert "include_rows: true" in source assert "HOVER_THROTTLE_MS = 120" in source - assert "VIEW_DEBOUNCE_MS = 200" in source + assert "VIEW_THROTTLE_MS = 120" in source assert "envelope.v = payloadVersion" in source assert "restoreSelectionSeqs.delete(message.seq)" in source assert "restoringSelection" not in source