diff --git a/README.md b/README.md index 6c5483bf..f3c5c2af 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,44 @@ app = rx.App() app.add_page(index) ``` +For state-driven charts, declare the chart in the page and supply columns from +a `@reflex_xy.data` state method — the structure (channels, colormaps, axes) +is validated when `reflex run` compiles the app, while the columns ride the +app's own websocket as binary buffers, never through Reflex state: + +```python +from typing import TypedDict + +import numpy as np +import reflex as rx +import reflex_xy + + +class CloudData(TypedDict): + x: np.ndarray + y: np.ndarray + mag: np.ndarray + + +class Dash(rx.State): + points: int = 200_000 + + @reflex_xy.data + def cloud(self) -> CloudData: + rng = np.random.default_rng(7) + x = rng.normal(size=self.points) + y = x * 0.6 + rng.normal(scale=0.6, size=self.points) + return {"x": x, "y": y, "mag": np.hypot(x, y)} + + +def dashboard() -> rx.Component: + return reflex_xy.scatter_chart( + data=Dash.cloud, + x="x", y="y", color="mag", colormap="viridis", + height="460px", + ) +``` + Hover, pan, and zoom keep working. For charts driven by Reflex state, events, or live streams, see the [Reflex integration guide](https://reflex.dev/docs/xy/integrations/reflex/) and diff --git a/examples/reflex/README.md b/examples/reflex/README.md index 0fcd1ee9..226553b3 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -6,23 +6,30 @@ section carries a **Code** accordion showing its source via `inspect.getsource`. Chart data rides the app's own websocket as a second socket.io namespace of -binary columns; Reflex state holds only a token string per chart. +binary columns; Reflex state holds only a tiny handle per chart. Charts use +the data-bound component API — structure declared in the page, compiled to a +validated plan at `reflex run`, columns supplied by `@reflex_xy.data` — +except where a section demonstrates the tier its behavior genuinely needs. ## What it shows -1. **Live figure var + events** — a 1M-point drillable scatter from an - `@reflex_xy.figure` method, with `on_point_hover` / `on_point_click` / - `on_select_end` handlers. -2. **A chart driven by state vars** — a histogram whose bin count is a slider - and whose data is cross-filtered by the selection above; changing either - recomputes and re-publishes the figure under a stable token. +1. **The flagship, data-bound** — a 1M-point drillable scatter composed in + the page (`reflex_xy.chart(xy.scatter("x", "y", ...), data=Demo.cloud)`) + with `on_point_hover` / `on_point_click` / `on_select_end` handlers; + `@reflex_xy.data` supplies only the columns. +2. **The escape hatch: structure from state** — a histogram whose bin count + is a slider. Bins are chart *structure*, not columns, so this is an + `@reflex_xy.figure` method; its data is also cross-filtered by the + selection above, and changing either re-publishes the figure under a + stable token. 3. **A dynamically updating chart** — a line grown by a background task via `reflex_xy.append`. -4. **Data computed from `on_view_change`** — pan/zoom an overview and a detail - histogram recomputes from the points in the reported window. -5. **Fixed data, two ways** — a `xy.Chart` passed straight to `reflex_xy.chart` - (static payload tier) and a `reflex_xy.inline` token (fixed data served - through the kernel). +4. **Data computed from `on_view_change`** — pan/zoom a data-bound overview; + a second data var reads the reported window from state and republishes + only the in-view column into the detail histogram's fixed plan. +5. **Fixed data, two ways** — concrete columns passed as `data=` to a + composed chart (compiled to a static payload asset, no kernel) and a + `reflex_xy.inline` token (fixed data served through the kernel). 6. **The 100M drilldown, adapter-native** — the live drilldown scatter from [`examples/fastapi`](../fastapi) (identical seed-11 data and mark config, a density surface that drills into exact points on zoom) as a @@ -31,6 +38,19 @@ binary columns; Reflex state holds only a token string per chart. adapter's websocket namespace and the kernel's density tiers do all of it, so behavioral differences between the two apps isolate what that custom code adds. +7. **Legend hover-highlight and click-to-toggle** — named series on the + direct-Chart tier (hover dims, click hides client-side) beside a + categorical density scatter on an `inline()` token whose category clicks + re-bin kernel-side with the category masked out. +8. **Column republish under a stable handle** — the flat form of the same + API as §1 (`reflex_xy.scatter_chart(data=Demo.bound_cloud, x="x", ...)`); + a slider republishes only the columns while the compile-validated plan, + viewport, and selection stay put. +9. **`rx.cond` + `rx.foreach`** — a toggle conditionally swaps a composed + three-series board for `rx.foreach` small multiples over a + `list[DataHandle[SensorCols]]` var: one plan mounted once per handle, + column names compile-checked inside the loop, both cond branches + validated at `reflex run`. ## Run @@ -59,15 +79,15 @@ The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in ## Interaction contract checks -Section 1's badges are event counters, and its click/select handlers -deliberately republish the cloud behind its stable token (the title's -`handler revision`). Together they make the wrapper's restore contract -manually verifiable: +Section 1's badges are event counters, §2's histogram republishes on every +box-selection (the cross-filter), and §8's slider republishes columns under a +stable handle. Together they make the integration's restore contract manually +verifiable: -1. Box-select a large area. The `select` readout shows the exact total, the - bounded JSON row count, and `truncated`; the §2 histogram cross-filters. - The cloud must keep both its viewport and its selection highlight across - the republish, and the selection counter must increment exactly once. +1. Box-select a large area of the cloud. The `select` readout shows the exact + total, the bounded JSON row count, and `truncated`; the §2 histogram + cross-filters. The cloud must keep both its viewport and its selection + highlight, and the selection counter must increment exactly once. 2. Zoom until density drills into exact points, then click one. The `click` readout shows its canonical row ID, f64 data coordinates, and active keyboard modifiers; the click counter must increment exactly once. @@ -75,6 +95,9 @@ manually verifiable: the same click readout contract as pointer activation. 4. Clear the selection. The histogram returns to all points and the select counter increments exactly once again. +5. Drag the §8 slider. The bound scatter repaints at the new point count + while keeping its viewport — the plan (and the chart's identity) never + changes, only the columns. -A runaway counter or a viewport/selection reset after any of these reveals a -republish feedback loop or a restore regression. +A runaway counter or a viewport/selection reset after any of these reveals an +event feedback loop or a restore regression. diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 0b2975dc..eb1f0402 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,22 +1,31 @@ """XY Reflex showcase: ways to link chart data into a Reflex app. -One page of six sections; each has a "Code" accordion showing its own source -via `inspect.getsource`. - -1. **Live figure var + events.** A 1M-point drillable scatter from an - ``@reflex_xy.figure`` state method; its data rides the app websocket while - Reflex state holds only the token. Hover, click, and box-select arrive as +One page of nine sections; each has a "Code" accordion showing its own source +via `inspect.getsource`. Charts use the data-bound component API — structure +declared in the page, compiled to a validated plan at ``reflex run``, columns +supplied by ``@reflex_xy.data`` state methods — except where a section +demonstrates the tier that behavior genuinely needs: ``@reflex_xy.figure`` +when the chart *structure* depends on state (§2), ``reflex_xy.append`` +streaming (§3), and ``reflex_xy.inline`` for shared fixed data (§5–§7). + +1. **The flagship, data-bound.** A 1M-point drillable scatter composed in the + page (``reflex_xy.chart(xy.scatter("x", "y", ...), data=Demo.cloud)``); + ``@reflex_xy.data`` supplies the columns over the app websocket while + Reflex state holds only a handle. Hover, click, and box-select arrive as ordinary Reflex events. -2. **A chart driven by state vars.** A histogram whose bin count is a slider var - and whose data is cross-filtered by §1's box-selection; changing either - recomputes the figure and re-publishes it under a stable token. +2. **The escape hatch: structure from state.** A histogram whose bin count is + a slider var — bins are chart *structure*, not columns, so this is an + ``@reflex_xy.figure`` method; its data is also cross-filtered by §1's + box-selection, and changing either re-publishes the figure under a stable + token. 3. **A dynamically updating chart.** A line grown from a background task via ``reflex_xy.append``. -4. **Data computed from ``on_view_change``.** Pan/zoom an overview scatter; a - detail figure recomputes from the window the view-change event reports. -5. **Fixed data, two ways.** A ``xy.Chart`` passed straight to - ``reflex_xy.chart`` (static payload tier) and a ``reflex_xy.inline`` token - (fixed data served through the kernel). +4. **Data computed from ``on_view_change``.** Pan/zoom a data-bound overview; + a second data var reads the reported window from state and republishes + only the in-view columns — the detail histogram's plan never changes. +5. **Fixed data, two ways.** Concrete columns passed as ``data=`` to a + composed chart (compiled to a static payload asset) and a + ``reflex_xy.inline`` token (fixed data served through the kernel). 6. **The drilldown, adapter-native.** The 100M-point live drilldown scatter from ``examples/fastapi`` — identical data and mark config — as one ``reflex_xy.inline`` token with zero transport code, for A/B-ing the two @@ -27,6 +36,16 @@ density scatter behind an ``inline()`` token — clicking a category row sends ``legend_toggle`` over the app websocket and the kernel re-bins the surface with that category masked out (§34). +8. **Column republish under a stable handle.** The §8 scatter's slider + republishes only the columns (``reflex_xy.scatter_chart(data=..., x="x", + ...)``); the compile-validated plan stays put, and viewport and selection + survive every republish. +9. **``rx.cond`` + ``rx.foreach``.** Charts are ordinary Reflex components: + a toggle conditionally swaps a composed three-series board for + ``rx.foreach`` small multiples over a ``list[DataHandle[...]]`` var — + one compile-validated plan, one mount per handle, column names checked + inside the loop (fact R7), and both cond branches validated at + ``reflex run``. Run from ``examples/reflex``:: @@ -37,18 +56,77 @@ import asyncio import inspect +import math import os import warnings from functools import lru_cache -from typing import Any +from typing import Any, TypedDict import numpy as np import reflex as rx import reflex_xy import xy +from reflex_xy import DataHandle from reflex_xy.tokens import BUILDER_ATTR + +def _clamped_slider_value(value: object, lo: int, hi: int) -> int: + """Server-side bound for a slider event value. + + The browser payload is untrusted input and these values size server + allocations; the slider's min/max are UI hints, not a security boundary. + Validate the shape and clamp the number before it reaches state. + """ + if not isinstance(value, (list, tuple)) or not value: + msg = f"slider event must be a non-empty [number] list, got {value!r}" + raise ValueError(msg) + first = value[0] + if isinstance(first, bool) or not isinstance(first, (int, float)) or not math.isfinite(first): + msg = f"slider value must be a finite number, got {first!r}" + raise ValueError(msg) + return max(lo, min(hi, int(first))) + + +class CloudCols(TypedDict): + """Schema of the §1 and §8 data vars — the factories compile-check the + column names in the page against these keys (design fact R7).""" + + x: np.ndarray + y: np.ndarray + mag: np.ndarray + + +class ScanCols(TypedDict): + """Schema of the §4 overview data var.""" + + t: np.ndarray + value: np.ndarray + + +class InViewCols(TypedDict): + """Schema of the §4 detail data var (the windowed values).""" + + value: np.ndarray + + +class SensorCols(TypedDict): + """Schema of one §9 sensor — the rx.foreach element var keeps this + parametrization, so column names are compile-checked inside the loop.""" + + t: np.ndarray + reading: np.ndarray + + +class SensorBoard(TypedDict): + """Schema of the §9 combined board (all sensors in one table).""" + + t: np.ndarray + alpha: np.ndarray + beta: np.ndarray + gamma: np.ndarray + + POINTS = 1_000_000 RNG_SEED = 11 @@ -84,22 +162,19 @@ async def _magnitudes() -> tuple[np.ndarray, np.ndarray]: return x, mag -# --- fixed-data charts (module scope) --------------------------------------- +@lru_cache(maxsize=1) +def _sensors() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Three correlated sensor traces for §9 (t, alpha, beta, gamma).""" + rng = np.random.default_rng(17) + t = np.linspace(0.0, 48.0, 2400) + base = 20.0 + 6.0 * np.sin(t / 5.0) + alpha = base + rng.normal(0.0, 0.7, t.size) + beta = base * 0.8 + 4.0 * np.sin(t / 3.0 + 1.2) + rng.normal(0.0, 0.7, t.size) + gamma = base * 1.15 - 3.0 * np.cos(t / 7.0) + rng.normal(0.0, 0.7, t.size) + return t, alpha, beta, gamma -def sparkline_chart() -> xy.Chart: - """A fixed chart passed directly to ``reflex_xy.chart``, which compiles it - to a static payload asset.""" - t = np.linspace(0.0, 6.0 * np.pi, 4000) - decay = np.exp(-t / 9.0) - return xy.line_chart( - xy.line(t, np.sin(t) * decay, name="signal"), - xy.line(t, decay, name="envelope"), - xy.x_axis(label="t"), - title="static payload tier", - width="100%", - height=240, - ) +# --- fixed-data charts (module scope) --------------------------------------- def orbits_chart() -> xy.Chart: @@ -121,7 +196,7 @@ def orbits_chart() -> xy.Chart: # Registered at import; the content-addressed token resolves on any backend # worker. -ORBITS_TOKEN = reflex_xy.inline(orbits_chart()) +ORBITS = reflex_xy.inline(orbits_chart()) # --- legend interactivity (§7) ---------------------------------------------- @@ -183,7 +258,7 @@ def legend_category_chart() -> xy.Chart: # Kernel-served so category toggles reach `legend_toggle` and the masked # re-bin path; a static payload would only filter the local sample overlay. -LEGEND_CATS_TOKEN = reflex_xy.inline(legend_category_chart()) +LEGEND_CATS = reflex_xy.inline(legend_category_chart()) # --- the live drilldown, adapter-native (§6) -------------------------- @@ -256,26 +331,22 @@ def drilldown_chart(n: int = DRILLDOWN_POINTS) -> xy.Chart: # One shared kernel-backed figure for every viewer, expressed as a single # inline() token; the registry keeps it process-global. -DRILLDOWN_TOKEN = reflex_xy.inline(drilldown_chart()) +DRILLDOWN = reflex_xy.inline(drilldown_chart()) # --- state ------------------------------------------------------------------ class Demo(rx.State): - """Charts are figure vars; everything else is ordinary app state.""" + """Data-bound charts read columns from ``@reflex_xy.data`` vars; the §2 + histogram is an ``@reflex_xy.figure`` var (its structure reads state); + everything else is ordinary app state.""" # §1 semantic events hovered: dict = {} clicked: dict = {} click_events: int = 0 select_events: int = 0 - # Click/select handlers bump this and the cloud's title reads it, so every - # event deliberately republishes the source figure behind its stable - # token. The wrapper must keep the viewport and selection across that - # republish without re-dispatching events (no feedback loop) — the - # counters above make a violation visible as a runaway count. - interaction_revision: int = 0 # §2 state-driven + cross-filter bins: int = 60 sel_active: bool = False @@ -291,23 +362,12 @@ class Demo(rx.State): view_x1: float = 0.0 visible: int = 0 - @reflex_xy.figure - def cloud(self) -> xy.Chart: + @reflex_xy.data + def cloud(self) -> CloudCols: + # Columns only — the chart structure lives in the page (cloud_view) + # as a compile-validated plan; this method never runs at compile. x, y, mag = _cloud(POINTS) - return xy.scatter_chart( - xy.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), - # hover and click are off by default; enable them so the point - # events reach the handlers below (select/pan/zoom are on already). - xy.interaction_config(hover=True, click=True), - xy.x_axis(label="feature A"), - xy.y_axis(label="feature B"), - title=( - f"{POINTS // 1_000_000}M points, drillable · " - f"handler revision {self.interaction_revision}" - ), - width="100%", - height=460, - ) + return {"x": x, "y": y, "mag": mag} @reflex_xy.figure async def histogram(self) -> xy.Chart: @@ -334,38 +394,20 @@ def live(self) -> xy.Chart: height=240, ) - @reflex_xy.figure - def overview(self) -> xy.Chart: - x, y = _scan(120_000) - return xy.scatter_chart( - xy.scatter(x, y, opacity=0.5, density=True), - xy.interaction_config(zoom_axes=("x",)), - xy.x_axis(label="t"), - xy.y_axis(label="value"), - title="overview — zoom the x range", - width="100%", - height=240, - ) - - @reflex_xy.figure - def detail(self) -> xy.Chart: - # Recomputed from the window the overview last reported through - # `on_view_change`: a histogram of only the y-values currently in view. - x, y = _scan(120_000) + @reflex_xy.data + def scan_points(self) -> ScanCols: + t, value = _scan(120_000) + return {"t": t, "value": value} + + @reflex_xy.data + def in_view(self) -> InViewCols: + # Republished from the window the overview last reported through + # `on_view_change`: only the values currently in view. The detail + # histogram's plan is fixed; this data var is what changes. + t, value = _scan(120_000) if self.view_ready and self.view_x1 > self.view_x0: - y = y[(x >= self.view_x0) & (x <= self.view_x1)] - title = ( - f"detail — {y.size:,} points in view" - if self.view_ready - else "detail — pan/zoom the overview" - ) - return xy.histogram_chart( - xy.histogram(y, bins=48, color="#7c3aed"), - xy.x_axis(label="value in view"), - title=title, - width="100%", - height=240, - ) + value = value[(t >= self.view_x0) & (t <= self.view_x1)] + return {"value": value} @rx.event def on_hover(self, event: reflex_xy.PointHoverEvent): @@ -375,7 +417,6 @@ def on_hover(self, event: reflex_xy.PointHoverEvent): @rx.event def on_click(self, event: reflex_xy.PointClickEvent): self.click_events += 1 - self.interaction_revision += 1 modifiers = event.get("modifiers", {}) self.clicked = { "row": event.get("canonical_row_id"), @@ -386,7 +427,6 @@ def on_click(self, event: reflex_xy.PointClickEvent): @rx.event def on_select(self, event: reflex_xy.SelectEndEvent): self.select_events += 1 - self.interaction_revision += 1 selection = event.get("selection", {}) total = int(selection.get("total_count") or 0) bounds = selection.get("data_bounds") or {} @@ -404,7 +444,8 @@ def on_select(self, event: reflex_xy.SelectEndEvent): @rx.event def set_bins(self, value: list[int | float]): - self.bins = int(value[0]) + # mirrors the slider's min/max server-side: bins size an allocation + self.bins = _clamped_slider_value(value, 20, 160) @rx.event def on_view(self, event: reflex_xy.ViewChangeEvent): @@ -419,6 +460,57 @@ def on_view(self, event: reflex_xy.ViewChangeEvent): x, _ = _scan(120_000) self.visible = int(((x >= self.view_x0) & (x <= self.view_x1)).sum()) + # §8 data-bound component: state supplies columns, the page declares the + # chart. The slider republishes only the columns; the chart structure is + # a compile-validated plan baked into the JSX. + bound_points: int = 150_000 + + @reflex_xy.data + def bound_cloud(self) -> CloudCols: + rng = np.random.default_rng(23) + x = rng.normal(size=self.bound_points) + y = x * 0.6 + rng.normal(scale=0.6, size=self.bound_points) + return {"x": x, "y": y, "mag": np.hypot(x, y)} + + @rx.event + def set_bound_points(self, value: list[int | float]): + # mirrors the slider's min/max server-side: this value sizes the + # arrays bound_cloud allocates, so it must be clamped here, not + # trusted from the wire + self.bound_points = _clamped_slider_value(value, 10_000, 1_000_000) + + # §9 cond + foreach: one combined board or per-sensor small multiples. + split: bool = False + + @reflex_xy.data + def board(self) -> SensorBoard: + t, alpha, beta, gamma = _sensors() + return {"t": t, "alpha": alpha, "beta": beta, "gamma": gamma} + + @reflex_xy.data + def sensor_alpha(self) -> SensorCols: + t, alpha, _, _ = _sensors() + return {"t": t, "reading": alpha} + + @reflex_xy.data + def sensor_beta(self) -> SensorCols: + t, _, beta, _ = _sensors() + return {"t": t, "reading": beta} + + @reflex_xy.data + def sensor_gamma(self) -> SensorCols: + t, _, _, gamma = _sensors() + return {"t": t, "reading": gamma} + + @rx.var + def sensor_handles(self) -> list[DataHandle[SensorCols]]: + """A typed handle list: rx.foreach charts stay schema-checked (R7).""" + return [self.sensor_alpha, self.sensor_beta, self.sensor_gamma] + + @rx.event + def toggle_split(self): + self.split = not self.split + @rx.event(background=True) async def stream(self): async with self: @@ -517,10 +609,19 @@ def kv(label: str, value: Any) -> rx.Component: ) -# §1 wiring — the live figure var and its semantic events +# §1 wiring — the chart composed in the page, bound to the cloud data var. +# Marks and chrome are plain xy nodes compiled to a validated plan at +# `reflex run`; a wrong column, colormap, or kwarg fails the compile. def cloud_view() -> rx.Component: return reflex_xy.chart( - figure=Demo.cloud, + xy.scatter("x", "y", color="mag", colormap="viridis", opacity=0.8, density=True), + # hover and click are off by default; enable them so the point + # events reach the state handlers (select/pan/zoom are on already). + xy.interaction_config(hover=True, click=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), + data=Demo.cloud, + title=f"{POINTS // 1_000_000}M points, drillable — declared in the page", on_point_hover=Demo.on_hover, on_point_click=Demo.on_click, on_select_end=Demo.on_select, @@ -563,24 +664,59 @@ def live_view() -> rx.Component: ) -# §4 wiring — a detail chart computed from the overview's view-change events +# §4 wiring — two data-bound charts: the overview reports its window through +# on_view_change; the in_view data var reads that window from state and +# republishes the filtered column into the detail histogram's fixed plan. def viewport_view() -> rx.Component: return rx.grid( reflex_xy.chart( - figure=Demo.overview, on_view_change=Demo.on_view, height="240px", id="overview" + xy.scatter("t", "value", opacity=0.5, density=True), + xy.interaction_config(zoom_axes=("x",)), + xy.x_axis(label="t"), + xy.y_axis(label="value"), + data=Demo.scan_points, + title="overview — zoom the x range", + on_view_change=Demo.on_view, + height="240px", + id="overview", + ), + reflex_xy.histogram_chart( + data=Demo.in_view, + values="value", + bins=48, + color="#7c3aed", + x_axis=xy.x_axis(label="value in view"), + title="detail — the points in view", + height="240px", + id="detail", ), - reflex_xy.chart(figure=Demo.detail, height="240px", id="detail"), columns="2", gap="1rem", width="100%", ) -# §5 wiring — the two fixed-data tiers +# §5 wiring — the two fixed-data tiers. Concrete columns as data= compile the +# composed chart to a static payload asset (renders kernel-less, works under +# `reflex export`); the inline() token serves fixed data through the kernel. +def sparkline_view() -> rx.Component: + t = np.linspace(0.0, 6.0 * np.pi, 4000) + decay = np.exp(-t / 9.0) + return reflex_xy.chart( + xy.line("t", "signal", name="signal"), + xy.line("t", "envelope", name="envelope"), + xy.x_axis(label="t"), + data={"t": t, "signal": np.sin(t) * decay, "envelope": decay}, + title="static payload tier", + height="240px", + id="inline", + ) + + def fixed_view() -> rx.Component: return rx.grid( - reflex_xy.chart(sparkline_chart(), height="240px", id="inline"), - reflex_xy.chart(figure=ORBITS_TOKEN, height="240px", id="orbits"), + sparkline_view(), + reflex_xy.chart(figure=ORBITS, height="240px", id="orbits"), columns="2", gap="1rem", width="100%", @@ -589,14 +725,97 @@ def fixed_view() -> rx.Component: # §6 wiring — the whole drilldown integration is this one line def drilldown_view() -> rx.Component: - return reflex_xy.chart(figure=DRILLDOWN_TOKEN, height="430px", id="drilldown") + return reflex_xy.chart(figure=DRILLDOWN, height="430px", id="drilldown") + + +# §8 wiring — the data-bound component: chart declared where it renders, +# state supplies only columns. Channels, colormap, and column names are all +# validated at compile (`reflex run` fails on a typo, not the browser). +def bound_view() -> rx.Component: + return rx.vstack( + reflex_xy.scatter_chart( + data=Demo.bound_cloud, + x="x", + y="y", + color="mag", + colormap="viridis", + mark_opacity=0.75, + density=True, + x_axis=xy.x_axis(label="feature A"), + y_axis=xy.y_axis(label="feature B"), + title="declared in the page, fed by @reflex_xy.data", + height="300px", + id="bound", + ), + rx.hstack( + rx.text("points", size="2", color_scheme="gray"), + rx.slider( + default_value=[150_000], + min=10_000, + max=1_000_000, + step=10_000, + on_change=Demo.set_bound_points, + id="bound-points", + ), + rx.text(Demo.bound_points, font_family="monospace", size="2", width="5.5rem"), + width="100%", + align="center", + spacing="3", + ), + width="100%", + spacing="2", + ) + + +# §9 wiring — charts are ordinary components, so rx.cond and rx.foreach just +# work. Both cond branches are built at page evaluation (both plans compile- +# validate); the foreach lambda runs once against the element var, producing +# ONE plan that mounts once per handle in the list. +def cond_foreach_view() -> rx.Component: + combined = reflex_xy.chart( + xy.line("t", "alpha", name="alpha"), + xy.line("t", "beta", name="beta"), + xy.line("t", "gamma", name="gamma"), + xy.legend(), + xy.x_axis(label="hours"), + data=Demo.board, + title="all sensors — one composed board", + height="260px", + id="board", + ) + multiples = rx.grid( + rx.foreach( + Demo.sensor_handles, + lambda handle: reflex_xy.area_chart( + data=handle, + x="t", + y="reading", + color="#0284c7", + mark_opacity=0.5, + height="180px", + ), + ), + columns="3", + gap="1rem", + width="100%", + ) + return rx.vstack( + rx.cond(Demo.split, multiples, combined), + rx.button( + rx.cond(Demo.split, "one combined board", "small multiples"), + on_click=Demo.toggle_split, + id="split-btn", + ), + width="100%", + spacing="2", + ) # §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(figure=LEGEND_CATS_TOKEN, height="300px", id="legend-cats"), + reflex_xy.chart(figure=LEGEND_CATS, height="300px", id="legend-cats"), columns="2", gap="1rem", width="100%", @@ -614,11 +833,12 @@ def index() -> rx.Component: size="3", ), section( - "1 · Live figure var + events", - "A 1M-point drillable scatter from an @reflex_xy.figure method. " - "Zoom to drill density into exact points; hover, click, and box-select. " - "Click and select handlers republish the chart itself (the title's " - "revision) — viewport and selection must survive each republish.", + "1 · The flagship, data-bound", + "A 1M-point drillable scatter declared in the page: composed xy " + "marks compile to a validated plan at reflex run, and " + "@reflex_xy.data supplies only the columns. Zoom to drill " + "density into exact points; hover, click, and box-select " + "arrive as ordinary Reflex events.", rx.vstack( cloud_view(), kv( @@ -640,8 +860,7 @@ def index() -> rx.Component: ), kv( "events", - f"{Demo.click_events} clicks · {Demo.select_events} selections · " - f"republish revision {Demo.interaction_revision}", + f"{Demo.click_events} clicks · {Demo.select_events} selections", ), width="100%", spacing="3", @@ -651,10 +870,12 @@ def index() -> rx.Component: ), ), section( - "2 · A chart driven by state vars", - "The histogram's bin count is a slider var, and its data is " - "cross-filtered by the box-selection above. Changing either " - "re-publishes the figure under a stable token.", + "2 · The escape hatch: structure from state", + "The histogram's bin count is a slider var — bins are chart " + "structure, not columns, so this chart is an @reflex_xy.figure " + "method rather than a plan. Its data is also cross-filtered by " + "the box-selection above; changing either re-publishes the " + "figure under a stable token.", histogram_view(), code_accordion(Demo.histogram, Demo.set_bins, histogram_view), ), @@ -667,8 +888,10 @@ def index() -> rx.Component: ), section( "4 · Data computed from on_view_change", - "Zoom the overview's x range; the detail histogram recomputes from " - "only the points in view, driven by the view-change event.", + "Zoom the overview's x range; the in_view data var reads the " + "reported window from state and republishes only the in-view " + "column — the detail histogram's plan never changes, its data " + "does.", rx.vstack( viewport_view(), kv( @@ -682,15 +905,16 @@ def index() -> rx.Component: width="100%", spacing="3", ), - code_accordion(Demo.overview, Demo.detail, Demo.on_view, viewport_view), + code_accordion(Demo.scan_points, Demo.in_view, Demo.on_view, viewport_view), ), section( "5 · Fixed data, two ways", - "Left: a xy.Chart passed straight to reflex_xy.chart, compiled to " - "a static payload asset. Right: a reflex_xy.inline token, whose " - "fixed data answers hover/pick from the kernel.", + "Left: concrete columns passed as data= to a composed chart, " + "compiled to a static payload asset (no kernel, works under " + "reflex export). Right: a reflex_xy.inline token, whose fixed " + "data answers hover/pick from the kernel.", fixed_view(), - code_accordion(sparkline_chart, orbits_chart, fixed_view), + code_accordion(sparkline_view, orbits_chart, fixed_view), ), section( f"6 · The {_point_label(DRILLDOWN_POINTS)} drilldown, adapter-native", @@ -715,6 +939,28 @@ def index() -> rx.Component: legend_view(), code_accordion(legend_series_chart, legend_category_chart, legend_view), ), + section( + "8 · Column republish under a stable handle", + "The flat single-mark form of the same API as §1: " + "reflex_xy.scatter_chart(data=Demo.bound_cloud, x='x', y='y', ...). " + "The slider republishes only the columns under a stable handle; " + "the compile-validated plan stays put, and viewport and " + "selection survive every republish.", + bound_view(), + code_accordion(Demo.bound_cloud, Demo.set_bound_points, bound_view), + ), + section( + "9 · rx.cond + rx.foreach", + "Charts are ordinary Reflex components. The toggle swaps a " + "composed three-series board for rx.foreach small multiples " + "over a list[DataHandle[SensorCols]] var: one compile-validated " + "plan, mounted once per handle, column names checked inside " + "the loop — and both cond branches validate at reflex run.", + cond_foreach_view(), + code_accordion( + Demo.sensor_alpha, Demo.sensor_handles, Demo.toggle_split, cond_foreach_view + ), + ), spacing="5", width="100%", ), diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py index d6516e9e..c230a660 100644 --- a/scripts/reflex_ws_smoke.py +++ b/scripts/reflex_ws_smoke.py @@ -14,6 +14,9 @@ point closes the semantic loop: kernel pick -> reflex event -> state delta -> DOM readout. 4. Streaming: clicking "go live" grows the live trace via `append` pushes. +5. Composition: toggling the §9 cond swaps the composed board for + `rx.foreach` small multiples over a `list[DataHandle]` var — new plan + charts mount and subscribe live. Usage: python3 scripts/reflex_ws_smoke.py [--frontend http://localhost:3100] @@ -242,9 +245,11 @@ def main() -> None: with ChromiumSession(chromium, gl="software", sandbox=False) as session: probe = Probe(session, args.frontend) - # 1) every chart mounts a view (seven live sources + one static Chart) + # 1) every chart mounts a view (ten live sources + two static charts; + # composite plan views mount as their data handles hydrate; the §9 + # foreach multiples mount only after the cond toggle, step 6) probe.wait_for( - "window.__xy_views && window.__xy_views.size >= 7", + "window.__xy_views && window.__xy_views.size >= 10", timeout_s=120.0, label="mounted chart views", ) @@ -257,13 +262,16 @@ def main() -> None: if len(ws) != 1: failures.append(f"expected exactly 1 backend websocket (shared transport), got {ws}") - # 2b) the direct-Chart mount is truly static: it never subscribes. The - # seven live sources (five figure vars + two inline() tokens: the - # orbits and the §6 drilldown) each sub. + # 2b) the static mounts (the §5 payload-asset chart and the §7 direct + # Chart) never subscribe. The ten live sources — two figure vars + # (§2 histogram, §3 live), three inline() tokens (orbits, §6 + # drilldown, legend-cats), and five data-bound plan charts (§1 + # cloud, §4 overview + detail, §8 bound, §9 board), whose composed + # xyp1 tokens sub once their data handles hydrate — each sub. subs = probe.sent_ws_frames('"sub"') print(f"sub frames sent: {len(subs)}") - if len(subs) < 7: - failures.append(f"expected >= 7 sub frames (live sources), got {len(subs)}") + if len(subs) < 10: + failures.append(f"expected >= 10 sub frames (live sources), got {len(subs)}") # 3) pixels: every chart paints ink inside its rect (full-page shot, # rects in page coordinates so below-the-fold charts count too). @@ -283,6 +291,7 @@ def main() -> None: ("live", 0.005), ("inline", 0.005), ("drilldown", 0.02), + ("bound", 0.02), ) for chart_id, min_ink in checks: frac = ink_fraction(png, probe.rect(chart_id, page_coords=True), 1.0) @@ -354,6 +363,28 @@ def main() -> None: n_after = probe.eval("window.__xy_views.get('live').gpuTraces[0].n") print(f"live stream: {n_before} -> {n_after} vertices (append pushes)") + # 6) §9 cond/foreach: toggling the split swaps the cond branch from + # the composed board to an rx.foreach grid of plan charts bound to + # a list[DataHandle] var — the board unmounts, three multiples + # mount and sub (net +2 views). + views_before = probe.eval("window.__xy_views.size") + probe.scroll_to("split-btn") + split = probe.eval( + "(() => { const b = document.getElementById('split-btn');" + " const r = b.getBoundingClientRect();" + " return {x: r.x + r.width / 2, y: r.y + r.height / 2}; })()" + ) + probe.mouse("mouseMoved", split["x"], split["y"]) + probe.mouse("mousePressed", split["x"], split["y"], button="left", buttons=1, clickCount=1) + probe.mouse("mouseReleased", split["x"], split["y"], button="left", buttons=0, clickCount=1) + probe.wait_for( + f"window.__xy_views.size >= {views_before} + 2", + timeout_s=30.0, + label="foreach small multiples mounting after the cond toggle", + ) + views_after = probe.eval("window.__xy_views.size") + print(f"cond/foreach: {views_before} -> {views_after} mounted views after split toggle") + if args.screenshot: Path(args.screenshot).write_bytes(probe.screenshot()) print(f"saved {args.screenshot}") diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 4448cdbc..efc1a1cb 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -602,7 +602,10 @@ and an error seam (`on_error`) for room-wide failures that answer no request. **Composite tokens.** The two halves compose into one figure identity — -`xyp1||` — subscribed as a unit. Rooms, versions, `mid` +`xyp1||` — assembled by the wrapper client-side from +the `plan` and `data` props, and subscribed once the data handle hydrates +(an empty handle token means "not ready", exactly as for figure handles). +Rooms, versions, `mid` addressing, the attachment cap, and every message below the subscribe path treat it as an ordinary `fig` string; the envelope grew no fields. Serving it = `plan_of(digest)` + columns (registry hit, else `rebuild_data` re-runs @@ -629,7 +632,10 @@ depends on that same session republishing later. Pinned by stays loud: a bind that stops matching (possible only for untyped data vars) logs server-side, releases the composite entry, and answers the room `err {resync}`; a stale digest -(hot-reload drift) answers `err {resync}` naming the digest. +(hot-reload drift) answers `err {resync}` naming the digest. The client +bounds consecutive err-triggered resyncs (5 without an intervening +payload), so a permanently failing identity settles into a visible console +error instead of a subscribe loop. **Republish ordering.** Dependent rebuilds run outside the registry mutex (they execute user-scale figure builds), so two republishes of one data @@ -1024,18 +1030,27 @@ python/reflex_xy/ payload_asset.py static tier: Chart -> content-addressed XYBF asset in assets/xy/ (§3.4) assets/ XYChart.jsx; links xy's installed render client -examples/reflex/ (repo root) Reflex showcase: figure-var drilldown with - hover/click/select events, a slider-driven + - cross-filtered histogram, a streaming line, an - on_view_change-computed detail chart, both - fixed-data tiers (direct Chart + inline() token), - and the fastapi live drilldown served adapter- - natively from an inline() token (same data and - XY_LIVE_POINTS override, zero transport code — - the cross-host A/B for that chart), plus legend - hover-highlight and click-to-toggle (named series - client-side; a categorical density inline() token - whose category toggles re-bin kernel-side, §34) +examples/reflex/ (repo root) Reflex showcase on the §3.6 data-bound API: a + composed 1M drillable scatter with hover/click/ + select events, an on_view_change data var + republishing in-view columns into a fixed + histogram plan, a flat scatter whose slider + republishes columns under a stable handle, and + an rx.cond toggle between a composed board and + rx.foreach small multiples over a + list[DataHandle] var; the + @reflex_xy.figure escape hatch where structure + reads state (slider-driven + cross-filtered + histogram), a streaming line (append), both + fixed-data tiers (concrete data= columns -> + payload asset; inline() token), the fastapi live + drilldown served adapter-natively from an + inline() token (same data and XY_LIVE_POINTS + override, zero transport code — the cross-host + A/B for that chart), plus legend hover-highlight + and click-to-toggle (named series client-side; a + categorical density inline() token whose category + toggles re-bin kernel-side, §34) examples/fastapi/ (repo root) the same charts + a live 100M drilldown served from a plain FastAPI app (no committed HTML) tests/reflex_adapter/ token/registry/var/data-var/plan/factory/bridge/ diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index ebb02de4..1c70bd07 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -145,11 +145,18 @@ def test_fastapi_app_serves_live_charts_and_code() -> None: def test_reflex_app_shows_every_linking_method_and_event() -> None: src = REFLEX_APP.read_text(encoding="utf-8") required = [ - "@reflex_xy.figure", # live figure var - "reflex_xy.chart(", # the component + "@reflex_xy.data", # data-bound columns (§1/§4/§8/§9) + "data=Demo.cloud", # composed chart bound to a data var (§1) + "reflex_xy.scatter_chart(", # flat data-bound factory (§8) + "rx.cond(Demo.split", # conditional chart rendering (§9) + "rx.foreach(", # chart-per-handle rendering (§9) + "list[DataHandle[SensorCols]]", # the typed handle collection (R7) + "@reflex_xy.figure", # escape hatch: chart structure from state (§2) + "reflex_xy.chart(", # the component / composed factory "reflex_xy.append(", # streaming "reflex_xy.inline(", # inline() token tier - "sparkline_chart()", # static Chart tier passed directly + 'data={"t": t', # static tier: concrete columns -> payload asset (§5) + "legend_series_chart()", # static Chart tier passed directly (§7) # the FastAPI 100M drilldown, served adapter-natively (§6); both apps # honor the same point-count override for side-by-side comparison. "def drilldown_chart", @@ -189,14 +196,16 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None sys.path.insert(0, str(REFLEX_DIR)) module = _load(REFLEX_APP, "xy_reflex_demo_under_test") - # The Code accordion reads live source: figure vars unwrap to their builder, - # event handlers to their function — both include the decorator line. - assert "@reflex_xy.figure" in module._source(module.Demo.cloud) + # The Code accordion reads live source: figure/data vars unwrap to their + # builder, event handlers to their function — both include the decorator. + assert "@reflex_xy.data" in module._source(module.Demo.cloud) assert "def cloud" in module._source(module.Demo.cloud) + assert "@reflex_xy.figure" in module._source(module.Demo.histogram) assert "def on_view" in module._source(module.Demo.on_view) + assert "@reflex_xy.data" in module._source(module.Demo.bound_cloud) # The page composes without error and mints inline() handles at import. - assert module.ORBITS_TOKEN.token.startswith("xyin-") - assert module.DRILLDOWN_TOKEN.token.startswith("xyin-") + assert module.ORBITS.token.startswith("xyin-") + assert module.DRILLDOWN.token.startswith("xyin-") assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None