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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ code comments cite dossier sections (e.g. §16 = deep-zoom re-centering).
(one-way dependency onto the public composition API; guardrails in
`tests/pyplot/test_boundaries.py`). Corpus-defined compatibility:
`tests/pyplot/corpus/` + `docs/matplotlib-compat.md`.
- `python/reflex-xy/` — the Reflex adapter, a separate distributable
package (`reflex_xy`; design: `docs/design/reflex-integration.md`). Chart
data rides the app's own websocket as a second socket.io namespace;
figures live in a per-process registry rebuilt from Reflex state on miss.
Depends on `xy` + `reflex`; `xy` itself must never import
reflex. The render client is linked out of the installed `xy`
package at app compile (no second copy to drift), and the adapter stays
out of the root `xy` sdist (`scripts/verify_sdist.py` enforces it).
Tests: `tests/reflex_adapter/` (skip unless reflex installed).
- `js/src/*.js` — the render client as ordered parts (concat order in
`js/build.mjs`; exports live only in `60_entries.js`), one dependency-free ES
module. **No npm packages.** `node js/build.mjs` copies it to
Expand All @@ -47,7 +56,9 @@ node js/build.mjs # regenerate static/ after JS edits
python3 scripts/abi_smoke.py # C-ABI seam, stdlib only (no PyPI needed)
python3 scripts/render_smoke_nonumpy.py # WebGL2 render path in headless Chromium
uv venv && uv pip install -e ".[dev]"
uv pip install -e "python/reflex-xy[dev]" # enables tests/reflex_adapter (installs reflex)
uv run pytest # native core required (no fallback)
python3 scripts/reflex_ws_smoke.py # browser E2E vs a running reflex-xy demo app
uv run ruff check . && uv run ruff format . && uv run ty check
uv run python scripts/bench.py # §12 benchmark harness
python3 scripts/bench_scatter_native.py --render # xy scatter, no deps
Expand Down
732 changes: 382 additions & 350 deletions docs/design/reflex-integration.md

Large diffs are not rendered by default.

19 changes: 13 additions & 6 deletions js/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,20 @@ const exportTail = markerLineEnd < 0 ? "" : src.slice(markerLineEnd + 1);
const iife = `(() => {\n${body}\nwindow.xy = { render, renderStandalone, decodeFrame, ChartView, MARK_KINDS, markOf };\n})();\n`;
new Function(iife);

// The Reflex adapter serves this same ESM client — but from the *installed*
// xy package (reflex_xy links xy/static/index.js at app compile), so
// there is no second copy to drift. One renderer for notebooks, static
// export, and Reflex.
const esm = body + "\n" + exportTail.trimStart();
const outputs = [
["index.js", body + "\n" + exportTail.trimStart()],
["standalone.js", iife],
[outDir, "index.js", esm],
[outDir, "standalone.js", iife],
];

if (checkOnly) {
const stale = [];
for (const [name, expected] of outputs) {
const path = join(outDir, name);
for (const [dir, name, expected] of outputs) {
const path = join(dir, name);
let actual = null;
try {
actual = readText(path);
Expand All @@ -251,7 +256,9 @@ if (checkOnly) {
}
console.log(`static JS bundles are fresh (${PARTS.length} parts)`);
} else {
mkdirSync(outDir, { recursive: true });
for (const [name, data] of outputs) writeFileSync(join(outDir, name), data);
for (const [dir, name, data] of outputs) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), data);
}
console.log(`built static/index.js and static/standalone.js from ${PARTS.length} parts`);
}
21 changes: 19 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ include = [
".github/workflows/ci.yml",
".github/workflows/codspeed.yml",
".github/workflows/release.yml",
"python",
# The xy package only — python/reflex-xy is its own distributable
# (own pyproject, depends on reflex) and must not ride in this sdist;
# scripts/verify_sdist.py enforces both the no-reflex-dependency rule
# and the ban on its reflex.lock frontend pins.
"python/xy",
"src",
"js/src",
"benchmarks",
Expand Down Expand Up @@ -89,6 +93,12 @@ exclude = [
"/node_modules/**",
"/target/**",
"/dist/**",
# Adapter tests ship with the reflex-xy package's repo checkout, not
# with the xy sdist (whose package they can't exercise anyway).
"/tests/reflex_adapter/**",
# Belt and braces for the include narrowing above: hatchling auto-admits
# readme-named files, which would smuggle adapter docs back in.
"/python/reflex-xy/**",
]

[tool.ruff]
Expand Down Expand Up @@ -136,6 +146,10 @@ exclude = ["examples/pdsh/*.ipynb"]
# dual-engine layout mirrors every cell (matplotlib then xy), so imports and
# helper defs repeat by design.
"examples/pdsh/*.ipynb" = ["E402", "E731", "E741", "F401", "F811", "I001", "UP030", "UP032", "B007"]
# Reflex state classes declare mutable defaults by design (reflex deep-copies
# them per instance); the demo heading's unicode × is intentional prose.
"python/reflex-xy/examples/**" = ["RUF012", "RUF001"]
"tests/reflex_adapter/*.py" = ["RUF012"]

[tool.ruff.lint.isort]
known-first-party = ["fastcharts", "xy"]
Expand All @@ -146,8 +160,11 @@ python = ".venv"
[tool.ty.src]
# Type-check the shippable library; tests/scripts/benchmarks use dynamic
# patterns (ctypes, deliberately subscripting known-non-None Optionals) that a
# pre-1.0 checker flags as false positives.
# pre-1.0 checker flags as false positives. The reflex demo app is excluded
# for the same reason: reflex state vars are Vars at class scope (`.length()`
# etc.), which a plain `dict` annotation can't express.
include = ["python"]
exclude = ["python/reflex-xy/examples"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
123 changes: 123 additions & 0 deletions python/reflex-xy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# reflex-xy

[xy](https://github.com/reflex-dev/xy) figures as first-class
[Reflex](https://reflex.dev) components: WebGL rendering, million-point
interactivity, and streaming updates — with chart data riding the app's
**existing websocket**, not a sidecar API.

Status: **prototype** implementing `docs/design/reflex-integration.md`.

## How it works

- **Control plane (Reflex-native).** The only chart state is a token string,
minted by a `@reflex_xy.figure` computed var. Semantic events —
`on_point_hover(row)`, `on_select_end(summary)` — arrive as ordinary Reflex
event handlers with small JSON payloads.
- **Data plane (xy-native).** A second socket.io namespace (`/_xy`)
multiplexed onto the app's own engine.io websocket ships the spec as JSON
and every data column as a binary frame (no JSON numbers, no base64, no
extra endpoints to reverse-proxy). Pan/zoom/hover round-trips go straight
to the figure kernel and never touch Reflex state.
- **No figure server.** Figures live in a per-process registry as
*rebuildable caches*: the token encodes `(client, state, var)`, so any
backend worker can re-run the builder against Reflex state (already
distributed via redis in prod) when a reconnect lands on it.

## Usage

```python
# rxconfig.py
import reflex as rx
import reflex_xy

config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
```

```python
# dash/dash.py
import numpy as np
import reflex as rx
import xy
import reflex_xy


class Dash(rx.State):
points: int = 200_000
hovered: dict = {}

@reflex_xy.figure
def chart(self) -> xy.Chart:
rng = np.random.default_rng(7)
xs = rng.normal(size=self.points)
ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)

@rx.event
def on_hover(self, row: dict):
self.hovered = row


def index() -> rx.Component:
return rx.vstack(
reflex_xy.chart(Dash.chart, on_point_hover=Dash.on_hover, height="460px"),
rx.text(Dash.hovered.to_string()),
width="100%",
)


app = rx.App()
```

Change `points` in an event handler and the chart re-publishes itself to
every subscriber — the token never changes, so nothing re-renders except
pixels.

Builders can be `async def` (they become reflex `AsyncComputedVar`s, same
rule as `rx.var`) — await a database, HTTP endpoint, or dataframe store:

```python
@reflex_xy.figure
async def remote(self) -> xy.Chart:
rows = await fetch_rows(self.query)
return xy.line_chart(xy.line(rows.t, rows.value))
```

Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or
background task pushes an incremental update over the same socket.

## Fixed-data charts

For a chart that doesn't depend on state, skip the state var entirely —
pass the Chart straight in:

```python
def index() -> rx.Component:
return reflex_xy.chart(
xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220),
height="220px",
)
```

That compiles the figure to a content-addressed binary asset at page build
and renders it with **zero backend involvement** — client-side hover,
pan/zoom, and density re-bin, same as `Figure.to_html()` exports; works
under `reflex export`. When a fixed chart still needs kernel round-trips
(deep drilldown into millions of points), register it once at module scope
instead:

```python
cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope

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

`inline()` tokens are content-addressed, so every backend worker derives
the same one — no state, no coordination. The escalation path is:
direct Chart (static) → `inline()` (fixed data, live kernel) →
`@reflex_xy.figure` (per-session, state-driven).

## Demo

`examples/demo_app/` in this directory is a runnable dashboard (drilldown
scatter, hover readout, box-select cross-filter, live streaming line).
7 changes: 7 additions & 0 deletions python/reflex-xy/examples/demo_app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.states
assets/external/
assets/xy/
.web
*.db
__pycache__/
*.py[cod]
25 changes: 25 additions & 0 deletions python/reflex-xy/examples/demo_app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# reflex-xy demo

One page exercising the whole integration: a 1M-point drillable density
scatter, hover row readout, box-select cross-filtering a histogram, a live
streaming line — all chart data on the app's own websocket — plus a static
chart passed directly as a `xy.Chart` (compiled to a payload asset, no
backend involvement at all).

```bash
# from the xy repo root
uv venv && uv pip install -e ".[dev]" -e python/reflex-xy
cd python/reflex-xy/examples/demo_app
reflex run
```

Open the printed URL (usually http://localhost:3000). Zoom deep into the
cloud to watch density drill into exact points; hover them for f64 rows;
box-select to cross-filter the histogram; hit "go live" for the stream.

Headless verification of the transport claims (one shared websocket, binary
payloads, drill, hover loop, streaming) — with the app running:

```bash
python3 scripts/reflex_ws_smoke.py --frontend http://localhost:3000
```
Empty file.
Loading
Loading