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
21 changes: 13 additions & 8 deletions examples/reflex/xy_reflex_demo/xy_reflex_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ async def stream(self):
def _source(obj: Any) -> str:
"""Source of a plain function, an ``@reflex_xy.figure`` var, or an
``@rx.event`` handler."""
# Class-level access to an object-valued computed var hands back reflex's
# casted wrapper; the declared var (with its fget) sits behind _original.
obj = getattr(obj, "_original", obj)
fget = getattr(obj, "_fget", None)
if fget is not None: # a @reflex_xy.figure / computed var
builder = getattr(fget, BUILDER_ATTR, None)
Expand Down Expand Up @@ -517,7 +520,7 @@ def kv(label: str, value: Any) -> rx.Component:
# §1 wiring — the live figure var and its semantic events
def cloud_view() -> rx.Component:
return reflex_xy.chart(
Demo.cloud,
figure=Demo.cloud,
on_point_hover=Demo.on_hover,
on_point_click=Demo.on_click,
on_select_end=Demo.on_select,
Expand All @@ -529,7 +532,7 @@ def cloud_view() -> rx.Component:
# §2 wiring — a chart driven by a slider and another chart's selection
def histogram_view() -> rx.Component:
return rx.vstack(
reflex_xy.chart(Demo.histogram, height="240px", id="hist"),
reflex_xy.chart(figure=Demo.histogram, height="240px", id="hist"),
rx.hstack(
rx.text("bins", size="2", color_scheme="gray"),
rx.slider(
Expand All @@ -549,7 +552,7 @@ def histogram_view() -> rx.Component:
# §3 wiring — a chart that grows from a background task
def live_view() -> rx.Component:
return rx.vstack(
reflex_xy.chart(Demo.live, height="240px", id="live"),
reflex_xy.chart(figure=Demo.live, height="240px", id="live"),
rx.button(
rx.cond(Demo.streaming, "stop stream", "go live"),
on_click=Demo.stream,
Expand All @@ -563,8 +566,10 @@ def live_view() -> rx.Component:
# §4 wiring — a detail chart computed from the overview's view-change events
def viewport_view() -> rx.Component:
return rx.grid(
reflex_xy.chart(Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview"),
reflex_xy.chart(Demo.detail, height="240px", id="detail"),
reflex_xy.chart(
figure=Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview"
),
reflex_xy.chart(figure=Demo.detail, height="240px", id="detail"),
columns="2",
gap="1rem",
width="100%",
Expand All @@ -575,7 +580,7 @@ def viewport_view() -> rx.Component:
def fixed_view() -> rx.Component:
return rx.grid(
reflex_xy.chart(sparkline_chart(), height="240px", id="inline"),
reflex_xy.chart(ORBITS_TOKEN, height="240px", id="orbits"),
reflex_xy.chart(figure=ORBITS_TOKEN, height="240px", id="orbits"),
columns="2",
gap="1rem",
width="100%",
Expand All @@ -584,14 +589,14 @@ def fixed_view() -> rx.Component:

# §6 wiring — the whole drilldown integration is this one line
def drilldown_view() -> rx.Component:
return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown")
return reflex_xy.chart(figure=DRILLDOWN_TOKEN, height="430px", id="drilldown")


# §7 wiring — legend interactivity ships with the charts; no handlers needed
def legend_view() -> rx.Component:
return rx.grid(
reflex_xy.chart(legend_series_chart(), height="300px", id="legend-series"),
reflex_xy.chart(LEGEND_CATS_TOKEN, height="300px", id="legend-cats"),
reflex_xy.chart(figure=LEGEND_CATS_TOKEN, height="300px", id="legend-cats"),
columns="2",
gap="1rem",
width="100%",
Expand Down
61 changes: 38 additions & 23 deletions python/reflex_xy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
spec/design/reflex-integration.md in the xy repo): chart data rides
the app's *existing* websocket as a second socket.io namespace — binary
columns, no JSON numbers, no extra endpoints to proxy. Figures live in a
per-process registry keyed by tokens; the tokens live in Reflex state. A
`@reflex_xy.figure` state method is both the chart definition and the
recovery recipe: any worker can rebuild the figure from state when a
reconnect lands somewhere new, so there is no central figure store to
operate.
per-process registry keyed by tokens; Reflex state holds only a small typed
handle wrapping the token. A `@reflex_xy.figure` state method is both the
chart definition and the recovery recipe: any worker can rebuild the figure
from state when a reconnect lands somewhere new, so there is no central
figure store to operate.

Quickstart::

Expand All @@ -32,7 +32,7 @@ def chart(self) -> xy.Chart:
return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)

def index() -> rx.Component:
return reflex_xy.chart(Dash.chart, height="460px")
return reflex_xy.chart(figure=Dash.chart, height="460px")

app = rx.App()
"""
Expand All @@ -54,6 +54,8 @@ def index() -> rx.Component:
"set_view": ".app",
"setup": ".app",
"chart": ".component",
"DataHandle": ".handles",
"FigureHandle": ".handles",
"CanonicalRowIdGroup": ".events",
"DataBounds": ".events",
"Modifiers": ".events",
Expand All @@ -79,6 +81,8 @@ def index() -> rx.Component:
"AsyncFigureVar",
"CanonicalRowIdGroup",
"DataBounds",
"DataHandle",
"FigureHandle",
"FigureRegistry",
"FigureVar",
"Modifiers",
Expand Down Expand Up @@ -154,22 +158,25 @@ def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))


def register(chart_or_figure: Any) -> str:
"""Imperatively register a chart; returns an opaque token for state.
def register(chart_or_figure: Any) -> "FigureHandle":
"""Imperatively register a chart; returns a typed handle for state.

Dev-tier API: the figure lives only in this process and cannot be
rebuilt after a worker restart or on another node — prefer
`@reflex_xy.figure` for anything long-lived (see the module doc).
The handle's ``.token`` is the registry key; pass the handle itself to
``chart(figure=...)`` (or store it in state). Dev-tier API: the figure
lives only in this process and cannot be rebuilt after a worker restart
or on another node — prefer `@reflex_xy.figure` for anything long-lived
(see the module doc).
"""
from .handles import FigureHandle
from .registry import _figure_of, registry

globals()["registry"] = registry

return registry.register(_figure_of(chart_or_figure))
return FigureHandle(registry.register(_figure_of(chart_or_figure)))


def inline(chart_or_figure: Any) -> str:
"""Register a fixed, kernel-backed chart at module scope; returns its token.
def inline(chart_or_figure: Any) -> "FigureHandle":
"""Register a fixed, kernel-backed chart at module scope; returns its handle.

For charts whose data never changes but which still want server-side
drilldown/picks on the shared websocket. Call at **module scope** so the
Expand All @@ -179,19 +186,21 @@ def inline(chart_or_figure: Any) -> str:
cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y)))

def index():
return reflex_xy.chart(cloud, height="460px")
return reflex_xy.chart(figure=cloud, height="460px")

The token is content-addressed — every worker independently derives the
same one, so the frontend's baked-in token resolves everywhere without
state or rebuild hooks. The entry is pinned (exempt from the TTL sweep):
there is no recipe to rebuild it from, so it lives with the process.
The handle's token is content-addressed — every worker independently
derives the same one, so the frontend's baked-in token resolves
everywhere without state or rebuild hooks. The entry is pinned (exempt
from the TTL sweep): there is no recipe to rebuild it from, so it lives
with the process.

Shared by design: one figure object serves every viewer, so kernel-side
drill state is shared too (like N notebook views of one widget). Data
depending on who's looking belongs in `@reflex_xy.figure`; data needing
no kernel at all can be passed straight to `reflex_xy.chart()` (static
payload tier).
"""
from .handles import FigureHandle
from .registry import _figure_of, registry

globals()["registry"] = registry
Expand All @@ -202,16 +211,21 @@ def index():
digest = hashlib.sha256(canonical + blob).hexdigest()[:20]
token = f"xyin-{digest}"
registry.publish(token, fig, broadcast=False, pinned=True)
return token
return FigureHandle(token)


def release(token: str) -> None:
"""Drop a registered figure (idempotent)."""
def release(token: "str | FigureHandle") -> None:
"""Drop a registered figure (idempotent). Takes a handle or its token."""
from .handles import token_of
from .registry import registry

globals()["registry"] = registry

registry.release(token)
resolved = token_of(token)
if resolved is None:
msg = f"expected a FigureHandle or figure token string, got {type(token).__name__}"
raise TypeError(msg)
registry.release(resolved)


if TYPE_CHECKING:
Expand All @@ -229,6 +243,7 @@ def release(token: str) -> None:
SelectionPayload,
ViewChangeEvent,
)
from .handles import DataHandle, FigureHandle
from .namespace import XY_NAMESPACE, XYNamespace
from .registry import FigureRegistry, registry
from .selections import resolve_selection
Expand Down
32 changes: 22 additions & 10 deletions python/reflex_xy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from reflex.plugins import Plugin

from .handles import FigureHandle, token_of
from .namespace import XYNamespace
from .registry import registry
from .state_bridge import make_rebuild_hook
Expand Down Expand Up @@ -89,8 +90,17 @@ def post_compile(self, **context: Any) -> None:
setup(app)


def _token(source: "str | FigureHandle") -> str:
"""Normalize a public figure argument (handle or bare token string)."""
token = token_of(source)
if token is None:
msg = f"expected a FigureHandle or figure token string, got {type(source).__name__}"
raise TypeError(msg)
return token


def append(
token: str,
token: "str | FigureHandle",
x: Any,
y: Any,
*,
Expand All @@ -103,26 +113,28 @@ def append(
Thin alias for `registry.append` — see its docstring for the threading
contract.
"""
registry.append(token, x, y, color=color, size=size, trace=trace)
registry.append(_token(token), x, y, color=color, size=size, trace=trace)


def set_view(token: str, ranges: Any, *, animate: bool = True, history: bool = True) -> None:
def set_view(
token: "str | FigureHandle", ranges: Any, *, animate: bool = True, history: bool = True
) -> None:
"""Out-of-band programmatic view patch (view-state.md §5.2).

Mirrors `append`: callable from any event handler, background task, or
thread; one wire message pushed room-wide, applied by every client
through the same clamped mutation path as a gesture, `source: "api"`.
"""
registry.set_view(token, ranges, animate=animate, history=history)
registry.set_view(_token(token), ranges, animate=animate, history=history)


def reset_view(token: str, axes: Any = None) -> None:
def reset_view(token: "str | FigureHandle", axes: Any = None) -> None:
"""Out-of-band navigation to the home ranges (room-wide)."""
registry.reset_view(token, axes)
registry.reset_view(_token(token), axes)


def select(
token: str,
token: "str | FigureHandle",
*,
range: Any = None,
polygon: Any = None,
Expand All @@ -132,12 +144,12 @@ def select(
"""Out-of-band programmatic selection (room-wide). Geometric forms
resolve client-side like a gesture; `rows=` resolves kernel-side and is
non-durable (see view-state.md §5.1)."""
registry.select(token, range=range, polygon=polygon, rows=rows, history=history)
registry.select(_token(token), range=range, polygon=polygon, rows=rows, history=history)


def clear_selection(token: str) -> None:
def clear_selection(token: "str | FigureHandle") -> None:
"""Out-of-band selection clear (room-wide)."""
registry.clear_selection(token)
registry.clear_selection(_token(token))


def reset_setup_for_tests() -> None:
Expand Down
Loading
Loading