Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/reflex/xy_reflex_demo/xy_reflex_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
3 changes: 2 additions & 1 deletion python/reflex-xy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 34 additions & 18 deletions python/reflex-xy/reflex_xy/assets/XYChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = {
Expand Down
11 changes: 8 additions & 3 deletions python/reflex-xy/reflex_xy/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -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",
}
"""

Expand All @@ -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"]
12 changes: 8 additions & 4 deletions spec/design/reflex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/reflex_adapter/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading