reflex-xy 0.0.1a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reflex_xy-0.0.1a1/.gitignore +17 -0
- reflex_xy-0.0.1a1/CHANGELOG.md +42 -0
- reflex_xy-0.0.1a1/PKG-INFO +188 -0
- reflex_xy-0.0.1a1/README.md +173 -0
- reflex_xy-0.0.1a1/pyproject.toml +62 -0
- reflex_xy-0.0.1a1/reflex_xy/__init__.py +166 -0
- reflex_xy-0.0.1a1/reflex_xy/app.py +146 -0
- reflex_xy-0.0.1a1/reflex_xy/assets/XYChart.jsx +602 -0
- reflex_xy-0.0.1a1/reflex_xy/assets/__init__.py +79 -0
- reflex_xy-0.0.1a1/reflex_xy/component.py +234 -0
- reflex_xy-0.0.1a1/reflex_xy/events.py +206 -0
- reflex_xy-0.0.1a1/reflex_xy/namespace.py +277 -0
- reflex_xy-0.0.1a1/reflex_xy/payload_asset.py +84 -0
- reflex_xy-0.0.1a1/reflex_xy/registry.py +369 -0
- reflex_xy-0.0.1a1/reflex_xy/selections.py +48 -0
- reflex_xy-0.0.1a1/reflex_xy/state_bridge.py +77 -0
- reflex_xy-0.0.1a1/reflex_xy/tokens.py +88 -0
- reflex_xy-0.0.1a1/reflex_xy/vars.py +185 -0
- reflex_xy-0.0.1a1/uv.lock +1954 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
target/
|
|
2
|
+
.venv/
|
|
3
|
+
smoke/
|
|
4
|
+
dist/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.ruff_cache/
|
|
9
|
+
node_modules/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.web/
|
|
12
|
+
**/assets/xy/*.xyf
|
|
13
|
+
# Generated render-client bundles (§33): built by `node js/build.mjs` and
|
|
14
|
+
# force-included into the wheel/sdist by hatch_build.py, never committed.
|
|
15
|
+
python/xy/static/index.js
|
|
16
|
+
python/xy/static/standalone.js
|
|
17
|
+
**/assets/external/
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **reflex-xy** (the Reflex adapter for xy) are
|
|
4
|
+
documented here. The format follows
|
|
5
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
|
|
7
|
+
The adapter versions independently of the xy core: its releases are tagged
|
|
8
|
+
`reflex-xy-vX.Y.Z` (the core keeps bare `vX.Y.Z`), and the distribution
|
|
9
|
+
version is derived from that tag — there is no number to bump in a file.
|
|
10
|
+
Cutting a release is: date the entry below, tag, push the tag. The release
|
|
11
|
+
gate (`scripts/check_release_version.py --package reflex-xy`) blocks the
|
|
12
|
+
publish unless this file carries a dated entry for the tagged version.
|
|
13
|
+
Pre-releases work the same way with a canonical PEP 440 suffix on the tag
|
|
14
|
+
(`reflex-xy-v0.0.1a1` → version `0.0.1a1`, likewise `bN`/`rcN`) and their own
|
|
15
|
+
dated entry here; pip only installs them on explicit request.
|
|
16
|
+
|
|
17
|
+
## [0.0.1a1] - 2026-07-24
|
|
18
|
+
|
|
19
|
+
## [0.0.1a1] — 2026-07-25
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- First packaged release line of the adapter: `reflex_xy.chart()` components,
|
|
23
|
+
`@reflex_xy.figure` state vars with rebuild-from-state recovery,
|
|
24
|
+
`XYPlugin`/`setup()` app wiring, the `/_xy` socket.io data-plane namespace
|
|
25
|
+
(binary columns on the app's own websocket), semantic hover/click/select/
|
|
26
|
+
view events, fixed-data tiers (direct `Chart` + `inline()` tokens), and
|
|
27
|
+
streaming `append`.
|
|
28
|
+
- The distribution version is derived from `reflex-xy-vX.Y.Z` git tags
|
|
29
|
+
(uv-dynamic-versioning with `pattern-prefix = "reflex-xy-"`) instead of a
|
|
30
|
+
number in `pyproject.toml`; canonical pre-release tags (`aN`/`bN`/`rcN`)
|
|
31
|
+
publish too, and builds between tags are versioned `<next>.devN+<commit>`,
|
|
32
|
+
which PyPI rejects by design. The adapter and core
|
|
33
|
+
tag namespaces are mutually invisible by pattern anchoring: this derivation
|
|
34
|
+
never sees bare `v*` tags, and the core's never sees `reflex-xy-*` ones.
|
|
35
|
+
Releases publish via the dedicated
|
|
36
|
+
`.github/workflows/release-reflex-xy.yml` workflow — deliberately separate
|
|
37
|
+
from the core's cross-compile pipeline, since the adapter is one pure wheel
|
|
38
|
+
plus one sdist with no build matrix — verified by
|
|
39
|
+
`scripts/verify_reflex_xy_dist.py`, gated on this changelog, published with
|
|
40
|
+
PyPI trusted publishing, and validated alongside the other workflows by
|
|
41
|
+
`make check-ci`. `reflex_xy.__version__` now reports the installed
|
|
42
|
+
distribution's version (an uninstalled tree reports `0.0.0`).
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reflex-xy
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Reflex integration for xy: WebGL charts over the app's own websocket
|
|
5
|
+
Project-URL: Repository, https://github.com/reflex-dev/xy
|
|
6
|
+
Project-URL: Changelog, https://github.com/reflex-dev/xy/blob/main/python/reflex-xy/CHANGELOG.md
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: reflex>=0.9.6
|
|
10
|
+
Requires-Dist: xy
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: aiohttp>=3.9; extra == 'dev'
|
|
13
|
+
Requires-Dist: uvicorn>=0.23; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# reflex-xy
|
|
17
|
+
|
|
18
|
+
[xy](https://github.com/reflex-dev/xy) figures as first-class
|
|
19
|
+
[Reflex](https://reflex.dev) components: WebGL rendering, million-point
|
|
20
|
+
interactivity, and streaming updates — with chart data riding the app's
|
|
21
|
+
**existing websocket**, not a sidecar API.
|
|
22
|
+
|
|
23
|
+
Status: **prototype** implementing `spec/design/reflex-integration.md`.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
- **Control plane (Reflex-native).** The only chart state is a token string,
|
|
28
|
+
minted by a `@reflex_xy.figure` computed var. Semantic events —
|
|
29
|
+
`on_point_hover(event)`, `on_point_click(event)`, `on_select_end(event)`, and
|
|
30
|
+
`on_view_change(event)` — arrive as ordinary Reflex
|
|
31
|
+
event handlers with small JSON payloads.
|
|
32
|
+
- **Data plane (xy-native).** A second socket.io namespace (`/_xy`)
|
|
33
|
+
multiplexed onto the app's own engine.io websocket ships the spec as JSON
|
|
34
|
+
and every data column as a binary frame (no JSON numbers, no base64, no
|
|
35
|
+
extra endpoints to reverse-proxy). Pan/zoom/hover round-trips go straight
|
|
36
|
+
to the figure kernel and never touch Reflex state.
|
|
37
|
+
- **No figure server.** Figures live in a per-process registry as
|
|
38
|
+
*rebuildable caches*: the token encodes `(client, state, var)`, so any
|
|
39
|
+
backend worker can re-run the builder against Reflex state (already
|
|
40
|
+
distributed via redis in prod) when a reconnect lands on it.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# rxconfig.py
|
|
46
|
+
import reflex as rx
|
|
47
|
+
import reflex_xy
|
|
48
|
+
|
|
49
|
+
config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# dash/dash.py
|
|
54
|
+
import numpy as np
|
|
55
|
+
import reflex as rx
|
|
56
|
+
import xy
|
|
57
|
+
import reflex_xy
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Dash(rx.State):
|
|
61
|
+
points: int = 200_000
|
|
62
|
+
hovered: dict = {}
|
|
63
|
+
|
|
64
|
+
@reflex_xy.figure
|
|
65
|
+
def chart(self) -> xy.Chart:
|
|
66
|
+
rng = np.random.default_rng(7)
|
|
67
|
+
xs = rng.normal(size=self.points)
|
|
68
|
+
ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
|
|
69
|
+
return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
|
|
70
|
+
|
|
71
|
+
@rx.event
|
|
72
|
+
def on_hover(self, row: dict):
|
|
73
|
+
self.hovered = row
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def index() -> rx.Component:
|
|
77
|
+
return rx.vstack(
|
|
78
|
+
reflex_xy.chart(Dash.chart, on_point_hover=Dash.on_hover, height="460px"),
|
|
79
|
+
rx.text(Dash.hovered.to_string()),
|
|
80
|
+
width="100%",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
app = rx.App()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Change `points` in an event handler and the chart re-publishes itself to
|
|
88
|
+
every subscriber — the token never changes, so nothing re-renders except
|
|
89
|
+
pixels.
|
|
90
|
+
|
|
91
|
+
Builders can be `async def` (they become reflex `AsyncComputedVar`s, same
|
|
92
|
+
rule as `rx.var`) — await a database, HTTP endpoint, or dataframe store:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
@reflex_xy.figure
|
|
96
|
+
async def remote(self) -> xy.Chart:
|
|
97
|
+
rows = await fetch_rows(self.query)
|
|
98
|
+
return xy.line_chart(xy.line(rows.t, rows.value))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or
|
|
102
|
+
background task pushes an incremental update over the same socket.
|
|
103
|
+
|
|
104
|
+
## Events and cross-filtering
|
|
105
|
+
|
|
106
|
+
Live token-backed charts emit versioned, bounded dictionaries with a stable
|
|
107
|
+
token and canonical row IDs. Selection rows can update ordinary State, which
|
|
108
|
+
causes dependent `@reflex_xy.figure` builders to republish without changing
|
|
109
|
+
their tokens or remounting the chart:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
class Dash(rx.State):
|
|
113
|
+
selected_groups: list[str] = []
|
|
114
|
+
|
|
115
|
+
@rx.event
|
|
116
|
+
def filter_groups(self, event: dict):
|
|
117
|
+
selection = event["selection"]
|
|
118
|
+
self.selected_groups = [] if selection["cleared"] else sorted({
|
|
119
|
+
row["color_category"] for row in selection["rows"]
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
def index():
|
|
123
|
+
return rx.grid(
|
|
124
|
+
reflex_xy.chart(Dash.groups, on_select_end=Dash.filter_groups),
|
|
125
|
+
reflex_xy.chart(Dash.filtered_detail),
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Selection JSON is capped and reports `total_count` plus `truncated`; call
|
|
130
|
+
`reflex_xy.resolve_selection(event)` in the handler when all canonical rows
|
|
131
|
+
are required. Hover and view changes are latest-wins throttled (view changes
|
|
132
|
+
stream live during a gesture and always end on a `final`-phase event), and
|
|
133
|
+
view/selection state survives dependent republishes without feedback events.
|
|
134
|
+
The complete envelopes, limits, clear/shared-handler/viewport examples, and
|
|
135
|
+
static-versus-live availability are documented in
|
|
136
|
+
`docs/engineering/design/reflex-integration.md`.
|
|
137
|
+
|
|
138
|
+
## Fixed-data charts
|
|
139
|
+
|
|
140
|
+
For a chart that doesn't depend on state, skip the state var entirely —
|
|
141
|
+
pass the Chart straight in:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
def index() -> rx.Component:
|
|
145
|
+
return reflex_xy.chart(
|
|
146
|
+
xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220),
|
|
147
|
+
height="220px",
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
That compiles the figure to a content-addressed binary asset at page build
|
|
152
|
+
and renders it with **zero backend involvement** — client-side hover,
|
|
153
|
+
pan/zoom, and density re-bin, same as `Figure.to_html()` exports; works
|
|
154
|
+
under `reflex export`. When a fixed chart still needs kernel round-trips
|
|
155
|
+
(deep drilldown into millions of points), register it once at module scope
|
|
156
|
+
instead:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope
|
|
160
|
+
|
|
161
|
+
def index() -> rx.Component:
|
|
162
|
+
return reflex_xy.chart(cloud, height="460px")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`inline()` tokens are content-addressed, so every backend worker derives
|
|
166
|
+
the same one — no state, no coordination. The escalation path is:
|
|
167
|
+
direct Chart (static) → `inline()` (fixed data, live kernel) →
|
|
168
|
+
`@reflex_xy.figure` (per-session, state-driven).
|
|
169
|
+
|
|
170
|
+
## Demo
|
|
171
|
+
|
|
172
|
+
The repository's [`examples/reflex/`](../../examples/reflex) is a runnable
|
|
173
|
+
showcase of every linking method: a drillable figure var with hover / click /
|
|
174
|
+
box-select events, a histogram driven by a slider and cross-filtered by the
|
|
175
|
+
selection, a streaming line, a detail chart recomputed from `on_view_change`,
|
|
176
|
+
and the two fixed-data tiers (a direct `xy.Chart` and an `inline()` token).
|
|
177
|
+
Each section shows its own source, read live with `inspect.getsource`.
|
|
178
|
+
|
|
179
|
+
For serving the same charts without Reflex, [`examples/fastapi/`](../../examples/fastapi)
|
|
180
|
+
renders standalone HTML and a live 100M-point drilldown from a plain FastAPI app.
|
|
181
|
+
|
|
182
|
+
## Versioning & releases
|
|
183
|
+
|
|
184
|
+
`reflex-xy` versions independently of `xy`: the distribution version is
|
|
185
|
+
derived from `reflex-xy-vX.Y.Z` git tags (the core uses bare `vX.Y.Z`), and
|
|
186
|
+
releases publish from the repository's `release-reflex-xy.yml` workflow after
|
|
187
|
+
a dated entry lands in this package's [CHANGELOG](CHANGELOG.md). Builds
|
|
188
|
+
between tags carry a `.devN+<commit>` version that PyPI rejects by design.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# reflex-xy
|
|
2
|
+
|
|
3
|
+
[xy](https://github.com/reflex-dev/xy) figures as first-class
|
|
4
|
+
[Reflex](https://reflex.dev) components: WebGL rendering, million-point
|
|
5
|
+
interactivity, and streaming updates — with chart data riding the app's
|
|
6
|
+
**existing websocket**, not a sidecar API.
|
|
7
|
+
|
|
8
|
+
Status: **prototype** implementing `spec/design/reflex-integration.md`.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
- **Control plane (Reflex-native).** The only chart state is a token string,
|
|
13
|
+
minted by a `@reflex_xy.figure` computed var. Semantic events —
|
|
14
|
+
`on_point_hover(event)`, `on_point_click(event)`, `on_select_end(event)`, and
|
|
15
|
+
`on_view_change(event)` — arrive as ordinary Reflex
|
|
16
|
+
event handlers with small JSON payloads.
|
|
17
|
+
- **Data plane (xy-native).** A second socket.io namespace (`/_xy`)
|
|
18
|
+
multiplexed onto the app's own engine.io websocket ships the spec as JSON
|
|
19
|
+
and every data column as a binary frame (no JSON numbers, no base64, no
|
|
20
|
+
extra endpoints to reverse-proxy). Pan/zoom/hover round-trips go straight
|
|
21
|
+
to the figure kernel and never touch Reflex state.
|
|
22
|
+
- **No figure server.** Figures live in a per-process registry as
|
|
23
|
+
*rebuildable caches*: the token encodes `(client, state, var)`, so any
|
|
24
|
+
backend worker can re-run the builder against Reflex state (already
|
|
25
|
+
distributed via redis in prod) when a reconnect lands on it.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# rxconfig.py
|
|
31
|
+
import reflex as rx
|
|
32
|
+
import reflex_xy
|
|
33
|
+
|
|
34
|
+
config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# dash/dash.py
|
|
39
|
+
import numpy as np
|
|
40
|
+
import reflex as rx
|
|
41
|
+
import xy
|
|
42
|
+
import reflex_xy
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Dash(rx.State):
|
|
46
|
+
points: int = 200_000
|
|
47
|
+
hovered: dict = {}
|
|
48
|
+
|
|
49
|
+
@reflex_xy.figure
|
|
50
|
+
def chart(self) -> xy.Chart:
|
|
51
|
+
rng = np.random.default_rng(7)
|
|
52
|
+
xs = rng.normal(size=self.points)
|
|
53
|
+
ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
|
|
54
|
+
return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
|
|
55
|
+
|
|
56
|
+
@rx.event
|
|
57
|
+
def on_hover(self, row: dict):
|
|
58
|
+
self.hovered = row
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def index() -> rx.Component:
|
|
62
|
+
return rx.vstack(
|
|
63
|
+
reflex_xy.chart(Dash.chart, on_point_hover=Dash.on_hover, height="460px"),
|
|
64
|
+
rx.text(Dash.hovered.to_string()),
|
|
65
|
+
width="100%",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
app = rx.App()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Change `points` in an event handler and the chart re-publishes itself to
|
|
73
|
+
every subscriber — the token never changes, so nothing re-renders except
|
|
74
|
+
pixels.
|
|
75
|
+
|
|
76
|
+
Builders can be `async def` (they become reflex `AsyncComputedVar`s, same
|
|
77
|
+
rule as `rx.var`) — await a database, HTTP endpoint, or dataframe store:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@reflex_xy.figure
|
|
81
|
+
async def remote(self) -> xy.Chart:
|
|
82
|
+
rows = await fetch_rows(self.query)
|
|
83
|
+
return xy.line_chart(xy.line(rows.t, rows.value))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or
|
|
87
|
+
background task pushes an incremental update over the same socket.
|
|
88
|
+
|
|
89
|
+
## Events and cross-filtering
|
|
90
|
+
|
|
91
|
+
Live token-backed charts emit versioned, bounded dictionaries with a stable
|
|
92
|
+
token and canonical row IDs. Selection rows can update ordinary State, which
|
|
93
|
+
causes dependent `@reflex_xy.figure` builders to republish without changing
|
|
94
|
+
their tokens or remounting the chart:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
class Dash(rx.State):
|
|
98
|
+
selected_groups: list[str] = []
|
|
99
|
+
|
|
100
|
+
@rx.event
|
|
101
|
+
def filter_groups(self, event: dict):
|
|
102
|
+
selection = event["selection"]
|
|
103
|
+
self.selected_groups = [] if selection["cleared"] else sorted({
|
|
104
|
+
row["color_category"] for row in selection["rows"]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
def index():
|
|
108
|
+
return rx.grid(
|
|
109
|
+
reflex_xy.chart(Dash.groups, on_select_end=Dash.filter_groups),
|
|
110
|
+
reflex_xy.chart(Dash.filtered_detail),
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Selection JSON is capped and reports `total_count` plus `truncated`; call
|
|
115
|
+
`reflex_xy.resolve_selection(event)` in the handler when all canonical rows
|
|
116
|
+
are required. Hover and view changes are latest-wins throttled (view changes
|
|
117
|
+
stream live during a gesture and always end on a `final`-phase event), and
|
|
118
|
+
view/selection state survives dependent republishes without feedback events.
|
|
119
|
+
The complete envelopes, limits, clear/shared-handler/viewport examples, and
|
|
120
|
+
static-versus-live availability are documented in
|
|
121
|
+
`docs/engineering/design/reflex-integration.md`.
|
|
122
|
+
|
|
123
|
+
## Fixed-data charts
|
|
124
|
+
|
|
125
|
+
For a chart that doesn't depend on state, skip the state var entirely —
|
|
126
|
+
pass the Chart straight in:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
def index() -> rx.Component:
|
|
130
|
+
return reflex_xy.chart(
|
|
131
|
+
xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220),
|
|
132
|
+
height="220px",
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That compiles the figure to a content-addressed binary asset at page build
|
|
137
|
+
and renders it with **zero backend involvement** — client-side hover,
|
|
138
|
+
pan/zoom, and density re-bin, same as `Figure.to_html()` exports; works
|
|
139
|
+
under `reflex export`. When a fixed chart still needs kernel round-trips
|
|
140
|
+
(deep drilldown into millions of points), register it once at module scope
|
|
141
|
+
instead:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope
|
|
145
|
+
|
|
146
|
+
def index() -> rx.Component:
|
|
147
|
+
return reflex_xy.chart(cloud, height="460px")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`inline()` tokens are content-addressed, so every backend worker derives
|
|
151
|
+
the same one — no state, no coordination. The escalation path is:
|
|
152
|
+
direct Chart (static) → `inline()` (fixed data, live kernel) →
|
|
153
|
+
`@reflex_xy.figure` (per-session, state-driven).
|
|
154
|
+
|
|
155
|
+
## Demo
|
|
156
|
+
|
|
157
|
+
The repository's [`examples/reflex/`](../../examples/reflex) is a runnable
|
|
158
|
+
showcase of every linking method: a drillable figure var with hover / click /
|
|
159
|
+
box-select events, a histogram driven by a slider and cross-filtered by the
|
|
160
|
+
selection, a streaming line, a detail chart recomputed from `on_view_change`,
|
|
161
|
+
and the two fixed-data tiers (a direct `xy.Chart` and an `inline()` token).
|
|
162
|
+
Each section shows its own source, read live with `inspect.getsource`.
|
|
163
|
+
|
|
164
|
+
For serving the same charts without Reflex, [`examples/fastapi/`](../../examples/fastapi)
|
|
165
|
+
renders standalone HTML and a live 100M-point drilldown from a plain FastAPI app.
|
|
166
|
+
|
|
167
|
+
## Versioning & releases
|
|
168
|
+
|
|
169
|
+
`reflex-xy` versions independently of `xy`: the distribution version is
|
|
170
|
+
derived from `reflex-xy-vX.Y.Z` git tags (the core uses bare `vX.Y.Z`), and
|
|
171
|
+
releases publish from the repository's `release-reflex-xy.yml` workflow after
|
|
172
|
+
a dated entry lands in this package's [CHANGELOG](CHANGELOG.md). Builds
|
|
173
|
+
between tags carry a `.devN+<commit>` version that PyPI rejects by design.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "uv-dynamic-versioning>=0.11"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reflex-xy"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Reflex integration for xy: WebGL charts over the app's own websocket"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"xy",
|
|
14
|
+
# Prototype dependency surface. The adapter needs: Component/Var/EventHandler
|
|
15
|
+
# (reflex-base), plus App/State/state_manager access, which today live in the
|
|
16
|
+
# full `reflex` distribution. Revisit when the reflex-base split grows a
|
|
17
|
+
# public state/app surface (spec/design/reflex-integration.md §4).
|
|
18
|
+
"reflex>=0.9.6",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
# tests/reflex_adapter drives the data plane over a real websocket:
|
|
23
|
+
# uvicorn serves the socket app, aiohttp backs socketio.AsyncClient.
|
|
24
|
+
dev = [
|
|
25
|
+
"aiohttp>=3.9",
|
|
26
|
+
"uvicorn>=0.23",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/reflex-dev/xy"
|
|
31
|
+
Changelog = "https://github.com/reflex-dev/xy/blob/main/python/reflex-xy/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
# Same tag-derived versioning policy as the xy core (see the root
|
|
34
|
+
# pyproject.toml for the full rationale), in a tag namespace of its own:
|
|
35
|
+
# the adapter releases from `reflex-xy-vX.Y.Z` tags, cut by
|
|
36
|
+
# .github/workflows/release-reflex-xy.yml. `pattern-prefix` inserts
|
|
37
|
+
# `reflex-xy-` after the default pattern's `^` anchor, which keeps the two
|
|
38
|
+
# release lines disjoint in both directions — this derivation only sees
|
|
39
|
+
# `reflex-xy-v*` tags, while the core's bare-`v` pattern (and the docs-deploy
|
|
40
|
+
# CalVer tags) can never match them.
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
source = "uv-dynamic-versioning"
|
|
43
|
+
|
|
44
|
+
[tool.uv-dynamic-versioning]
|
|
45
|
+
vcs = "git"
|
|
46
|
+
style = "pep440"
|
|
47
|
+
pattern-prefix = "reflex-xy-"
|
|
48
|
+
# Builds off a tagged commit get the bare version; anything else gets
|
|
49
|
+
# `<next>.devN+<commit>`, unpublishable to PyPI on purpose — only a tag can
|
|
50
|
+
# produce an uploadable version.
|
|
51
|
+
bump = true
|
|
52
|
+
# Only reached by a source tree with neither git metadata nor PKG-INFO; such
|
|
53
|
+
# a build still succeeds, at an obviously unreal version rather than a
|
|
54
|
+
# plausible-but-wrong one (unpacked sdists resolve from their own PKG-INFO).
|
|
55
|
+
fallback-version = "0.0.0"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["reflex_xy"]
|
|
59
|
+
# reflex_xy/assets/XYChart.jsx ships as ordinary package data; the render
|
|
60
|
+
# client is NOT packaged — it links out of the installed xy wheel at
|
|
61
|
+
# app compile time (see reflex_xy/assets/__init__.py), so it can never
|
|
62
|
+
# drift from the kernel that produces its payloads.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""reflex-xy: xy figures as first-class Reflex components.
|
|
2
|
+
|
|
3
|
+
The integration in one paragraph (full design:
|
|
4
|
+
spec/design/reflex-integration.md in the xy repo): chart data rides
|
|
5
|
+
the app's *existing* websocket as a second socket.io namespace — binary
|
|
6
|
+
columns, no JSON numbers, no extra endpoints to proxy. Figures live in a
|
|
7
|
+
per-process registry keyed by tokens; the tokens live in Reflex state. A
|
|
8
|
+
`@reflex_xy.figure` state method is both the chart definition and the
|
|
9
|
+
recovery recipe: any worker can rebuild the figure from state when a
|
|
10
|
+
reconnect lands somewhere new, so there is no central figure store to
|
|
11
|
+
operate.
|
|
12
|
+
|
|
13
|
+
Quickstart::
|
|
14
|
+
|
|
15
|
+
# rxconfig.py
|
|
16
|
+
config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
|
|
17
|
+
|
|
18
|
+
# dash/dash.py
|
|
19
|
+
import numpy as np
|
|
20
|
+
import reflex as rx
|
|
21
|
+
import xy
|
|
22
|
+
import reflex_xy
|
|
23
|
+
|
|
24
|
+
class Dash(rx.State):
|
|
25
|
+
points: int = 200_000
|
|
26
|
+
|
|
27
|
+
@reflex_xy.figure
|
|
28
|
+
def chart(self) -> xy.Chart:
|
|
29
|
+
rng = np.random.default_rng(7)
|
|
30
|
+
xs = rng.normal(size=self.points)
|
|
31
|
+
ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
|
|
32
|
+
return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
|
|
33
|
+
|
|
34
|
+
def index() -> rx.Component:
|
|
35
|
+
return reflex_xy.chart(Dash.chart, height="460px")
|
|
36
|
+
|
|
37
|
+
app = rx.App()
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from .app import XYPlugin, append, clear_selection, reset_view, select, set_view, setup
|
|
47
|
+
from .component import chart
|
|
48
|
+
from .events import (
|
|
49
|
+
CanonicalRowIdGroup,
|
|
50
|
+
DataBounds,
|
|
51
|
+
Modifiers,
|
|
52
|
+
PointClickEvent,
|
|
53
|
+
PointData,
|
|
54
|
+
PointHoverEvent,
|
|
55
|
+
ScreenPoint,
|
|
56
|
+
SelectEndEvent,
|
|
57
|
+
SelectionPayload,
|
|
58
|
+
ViewChangeEvent,
|
|
59
|
+
)
|
|
60
|
+
from .namespace import XY_NAMESPACE, XYNamespace
|
|
61
|
+
from .registry import FigureRegistry, _figure_of, registry
|
|
62
|
+
from .selections import resolve_selection
|
|
63
|
+
from .vars import AsyncFigureVar, FigureVar, figure
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"XY_NAMESPACE",
|
|
67
|
+
"AsyncFigureVar",
|
|
68
|
+
"CanonicalRowIdGroup",
|
|
69
|
+
"DataBounds",
|
|
70
|
+
"FigureRegistry",
|
|
71
|
+
"FigureVar",
|
|
72
|
+
"Modifiers",
|
|
73
|
+
"PointClickEvent",
|
|
74
|
+
"PointData",
|
|
75
|
+
"PointHoverEvent",
|
|
76
|
+
"ScreenPoint",
|
|
77
|
+
"SelectEndEvent",
|
|
78
|
+
"SelectionPayload",
|
|
79
|
+
"ViewChangeEvent",
|
|
80
|
+
"XYNamespace",
|
|
81
|
+
"XYPlugin",
|
|
82
|
+
"append",
|
|
83
|
+
"chart",
|
|
84
|
+
"clear_selection",
|
|
85
|
+
"figure",
|
|
86
|
+
"inline",
|
|
87
|
+
"register",
|
|
88
|
+
"registry",
|
|
89
|
+
"release",
|
|
90
|
+
"reset_view",
|
|
91
|
+
"resolve_selection",
|
|
92
|
+
"select",
|
|
93
|
+
"set_view",
|
|
94
|
+
"setup",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def __getattr__(name: str) -> str:
|
|
99
|
+
"""Resolve ``__version__`` lazily from the installed distribution.
|
|
100
|
+
|
|
101
|
+
The version is not written down in the source tree — it is derived from
|
|
102
|
+
the latest ``reflex-xy-vX.Y.Z`` git tag at build time (pyproject's
|
|
103
|
+
uv-dynamic-versioning config) and baked into the wheel's METADATA, so
|
|
104
|
+
package metadata is the only place that can answer this at runtime. An
|
|
105
|
+
uninstalled source tree reports the same unreal ``0.0.0`` the build-time
|
|
106
|
+
fallback uses.
|
|
107
|
+
"""
|
|
108
|
+
if name == "__version__":
|
|
109
|
+
from importlib.metadata import PackageNotFoundError
|
|
110
|
+
from importlib.metadata import version as _distribution_version
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
value = _distribution_version("reflex-xy")
|
|
114
|
+
except PackageNotFoundError:
|
|
115
|
+
value = "0.0.0"
|
|
116
|
+
globals()["__version__"] = value
|
|
117
|
+
return value
|
|
118
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register(chart_or_figure: Any) -> str:
|
|
122
|
+
"""Imperatively register a chart; returns an opaque token for state.
|
|
123
|
+
|
|
124
|
+
Dev-tier API: the figure lives only in this process and cannot be
|
|
125
|
+
rebuilt after a worker restart or on another node — prefer
|
|
126
|
+
`@reflex_xy.figure` for anything long-lived (see the module doc).
|
|
127
|
+
"""
|
|
128
|
+
return registry.register(_figure_of(chart_or_figure))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def inline(chart_or_figure: Any) -> str:
|
|
132
|
+
"""Register a fixed, kernel-backed chart at module scope; returns its token.
|
|
133
|
+
|
|
134
|
+
For charts whose data never changes but which still want server-side
|
|
135
|
+
drilldown/picks on the shared websocket. Call at **module scope** so the
|
|
136
|
+
registration side effect runs in every backend worker (page bodies only
|
|
137
|
+
run where the frontend compiles)::
|
|
138
|
+
|
|
139
|
+
cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y)))
|
|
140
|
+
|
|
141
|
+
def index():
|
|
142
|
+
return reflex_xy.chart(cloud, height="460px")
|
|
143
|
+
|
|
144
|
+
The token is content-addressed — every worker independently derives the
|
|
145
|
+
same one, so the frontend's baked-in token resolves everywhere without
|
|
146
|
+
state or rebuild hooks. The entry is pinned (exempt from the TTL sweep):
|
|
147
|
+
there is no recipe to rebuild it from, so it lives with the process.
|
|
148
|
+
|
|
149
|
+
Shared by design: one figure object serves every viewer, so kernel-side
|
|
150
|
+
drill state is shared too (like N notebook views of one widget). Data
|
|
151
|
+
depending on who's looking belongs in `@reflex_xy.figure`; data needing
|
|
152
|
+
no kernel at all can be passed straight to `reflex_xy.chart()` (static
|
|
153
|
+
payload tier).
|
|
154
|
+
"""
|
|
155
|
+
fig = _figure_of(chart_or_figure)
|
|
156
|
+
spec, blob = fig.build_payload()
|
|
157
|
+
canonical = json.dumps(spec, sort_keys=True, separators=(",", ":")).encode()
|
|
158
|
+
digest = hashlib.sha256(canonical + blob).hexdigest()[:20]
|
|
159
|
+
token = f"xyin-{digest}"
|
|
160
|
+
registry.publish(token, fig, broadcast=False, pinned=True)
|
|
161
|
+
return token
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def release(token: str) -> None:
|
|
165
|
+
"""Drop a registered figure (idempotent)."""
|
|
166
|
+
registry.release(token)
|