Metadata-Version: 2.4
Name: reflex-perspective
Version: 0.2.0
Summary: Perspective for Reflex: a WebAssembly analytics viewer (datagrid + WebGL charts) for large and streaming datasets, with client-only, server-only and replicated modes.
Author-email: Ernesto Crespo <ecrespo@gmail.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/ecrespo/reflex-perspective
Project-URL: Source, https://github.com/ecrespo/reflex-perspective
Project-URL: Issues, https://github.com/ecrespo/reflex-perspective/issues
Project-URL: Perspective, https://perspective-dev.github.io
Keywords: reflex,reflex-custom-components,perspective,datagrid,pivot,streaming,data-visualization,webassembly
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: reflex>=0.9.12
Provides-Extra: server
Requires-Dist: perspective-python==5.5.1; python_version >= "3.11" and extra == "server"
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: httpx; extra == "dev"
Requires-Dist: perspective-python==5.5.1; python_version >= "3.11" and extra == "dev"
Dynamic: license-file

# reflex-perspective

A [Reflex](https://reflex.dev) custom component for [Perspective](https://perspective-dev.github.io) —
the WebAssembly analytics engine and interactive viewer (datagrid + WebGL charts) built for
large and streaming datasets.

![Explorer](docs/images/explorer.png)

- **All three Perspective data architectures**
  - *Client-only*: data from Reflex state, a URL (CSV / JSON / NDJSON / Arrow) or a schema lives in a browser WebWorker.
  - *Server-only (virtual)*: tables live in `perspective-python` **inside the Reflex backend**; only the visible cells cross the wire.
  - *Client/server replicated*: the browser keeps a live copy of a server table and computes views locally.
- **Declarative props** for the whole `ViewerConfig` (`plugin`, `group_by`, `split_by`, `columns`, `filter`, `sort`, `expressions`, `aggregates`, `columns_config`, `theme`, `settings`, ...) mapped to `restore()`.
- **Multi-panel workspaces** (`restoreWorkspace`) with master panels and global cross-filters.
- **Streaming** through props (`update_rows`, `remove_keys`) or straight from event handlers (`rp.update`).
- **Perspective Custom Events as Reflex events**: `on_click`, `on_select`, `on_config_update`, `on_global_filter_update`, `on_layout_update`, `on_load`, `on_error`, ...
- **Imperative API from Python**: `rp.save`, `rp.restore`, `rp.download`, `rp.copy`, `rp.export`, `rp.reset`, `rp.toggle_config`, `rp.update`, `rp.remove`, `rp.clear`, `rp.add_panel`, ...
- All 16 bundled themes, every datagrid and chart plugin (X/Y Bar, Line, Area, Scatter, Density, Treemap, Sunburst, Heatmap, Candlestick, OHLC, Map Scatter/Line/Density).
- SSR-safe lazy WASM bootstrap, automatic WebSocket reconnection, works in `reflex run` dev and `--env prod`.

Built against **Reflex 0.9.12** and **Perspective 5.5.1** (`@perspective-dev/*` npm packages and `perspective-python`).

## Installation

```bash
pip install reflex-perspective            # client-only mode
pip install "reflex-perspective[server]"  # + perspective-python for server modes
```

The npm packages are installed by Reflex automatically on the first `reflex run`.
The client-only component works on Python 3.10+; the `server` extra needs Python 3.11+
(`perspective-python` 5.x only publishes `cp311-abi3` wheels).

## Quick start (client-only)

```python
import reflex as rx
import reflex_perspective as rp


class State(rx.State):
    rows: list[dict] = [
        {"region": "East", "product": "A", "sales": 120.5},
        {"region": "West", "product": "B", "sales": 98.1},
    ]
    plugin: str = "Y Bar"

    @rx.event
    def clicked(self, detail: dict):
        print(detail["row"], detail["config"]["filter"])


def index():
    return rp.perspective_viewer(
        id="sales",
        data=State.rows,  # list of rows, dict of columns, or CSV text
        plugin=State.plugin,
        group_by=["region"],
        split_by=["product"],
        columns=["sales"],
        theme="Pro Dark",
        on_click=State.clicked,
        height="500px",
    )


app = rx.App()
app.add_page(index)
```

Changing any prop re-applies only the difference with `restore()`; changing `data` calls
`table.replace()` and keeps the user's view.

## Server-hosted tables (perspective-python)

```python
import reflex as rx
import reflex_perspective as rp
from reflex_perspective import server as ps

hub = ps.get_hub()  # a perspective.Server + local client
hub.table(big_dataframe, name="orders", index="order_id")

app = rx.App(api_transformer=ps.perspective_api())  # WebSocket at /perspective
# or: ps.mount(app)  (keeps any existing api_transformer)


def page():
    return rx.hstack(
        # virtual: pivots/sorts run in Python, only the viewport is sent
        rp.perspective_viewer(server_url="/perspective", server_table="orders"),
        # replicated: the browser keeps a synced copy and computes locally
        rp.perspective_viewer(
            server_url="/perspective", server_table="orders", server_mode="replicated"
        ),
    )


class State(rx.State):
    @rx.event
    def add_order(self):
        hub.update("orders", [{"order_id": 42, "qty": 3}])  # every viewer updates
```

`server_url` may be a path (resolved against the Reflex backend URL, which also handles
the dev setup where frontend and backend run on different ports) or an absolute `ws://` URL
of any Perspective server. Feed tables from background tasks, lifespan tasks
(`ps.run_periodically(fn, interval)`) or plain threads — the hub is thread-safe and the
WebSocket handler marshals messages back onto the event loop. Engine requests run in the
event loop's default thread pool (pass `executor=` to `perspective_api()` / `mount()` to use
your own), so a heavy pivot on a large table never blocks other Reflex events.

### Access control

By default (as in 0.1.0) the endpoint only checks the browser `Origin` header against
Reflex's `cors_allowed_origins` (or `allowed_origins=[...]`), closing foreign connections
with code 1008, and **any client that reaches it can read and modify every hosted table**.
For any deployment with more than one user, set explicit origins and use **both** options
below (keyword-only on `perspective_api()`, `mount()`, `asgi_app()` and `serve()`):

```python
async def authorize_panel(websocket):  # sync or async
    user = await user_from_cookie(websocket.cookies.get("session"))
    if user is None:
        return 4401  # refuse: close code 4000-4999 (or 1008)
    if not user.can_view_analytics:
        return 4403
    return None  # accept


ps.mount(
    app,
    path="/analytics/ws",
    allowed_origins=["https://app.example.com"],
    authorize=authorize_panel,
    read_only=True,
)
```

- `authorize=` runs after the `Origin` check and **before** any Perspective session exists.
  A refusal accepts the socket and closes it at once with your code, so the browser sees it
  (closing before the handshake would turn it into an HTTP 403 and the browser would only
  see 1006). A hook that raises, or returns anything other than `None`, 4000–4999 or 1008,
  closes with **1011** and is logged.
- `read_only=True` forwards only frames carrying exactly one known **read** request of the
  pinned Perspective version (`ps.READ_VARIANTS`); writes (`update`, `remove`,
  `replace`/`clear`, table `delete`, new tables, joins), unknown variants and malformed or
  tampered frames close the socket with **4409** (`write_close_code=` to change it) and are
  logged by variant number and name, never by content. Writes from Python (`hub.update()`,
  lifespan tasks) keep reaching every viewer. On a `perspective-python` version without a
  verified read table, building the app raises `RuntimeError` unless you pass
  `read_variants=`.
- With `read_only=True`, a server viewer with `edit_mode="EDIT"`, `update_rows`,
  `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409 (and
  the viewer reconnects). Write through the hub instead. Read-only does not stop users from
  exporting what they can see (`view_to_*` reads stay allowed).
- `ps.request_variant()` and `ps.is_read_request()` are public, for apps that compose
  their own handler around `hub.serve()`.

`PerspectiveHub` helpers: `table()`, `get_table()`, `has_table()`, `table_names()`,
`update()`, `remove()`, `clear()`, `size()`, `query(name, **view_config)` and
`delete_table()`.

## Streaming

```python
rp.perspective_viewer(
    id="ticks",
    schema={"time": "datetime", "symbol": "string", "price": "float"},
    limit=5000,  # rolling window
    update_rows=State.batch,  # every new list is applied with table.update()
    plugin="Y Line",
    group_by=["time"],
    split_by=["symbol"],
    columns=["price"],
)


# or push rows without storing them in state:
@rx.event
def burst(self):
    return rp.update("ticks", rows)
```

## Workspaces

```python
rp.perspective_viewer(
    data=State.rows,
    workspace={
        "masters": ["regions"],  # selections here become global filters
        "layout": {
            "type": "split-layout",
            "orientation": "horizontal",
            "sizes": [0.4, 0.6],
            "children": [
                {"type": "tab-layout", "tabs": ["regions"]},
                {"type": "tab-layout", "tabs": ["chart"]},
            ],
        },
        "panels": {
            "regions": {
                "plugin": "Datagrid",
                "group_by": ["Region"],
                "columns": ["Sales"],
            },
            "chart": {
                "plugin": "Treemap",
                "group_by": ["Category"],
                "columns": ["Sales"],
            },
        },
    },
    on_global_filter_update=State.set_filters,
)
```

Panels without a `table` in the `workspace` prop are bound to the viewer's own table
automatically. `rp.restore_workspace()` and `rp.add_panel()` pass their config through
unchanged, so include `table` there.

## Props

| Prop | Description |
| --- | --- |
| `data` | Rows (`list[dict]`), columns (`dict[str, list]`) or CSV text. |
| `schema` | `{"col": "string" \| "integer" \| "float" \| "boolean" \| "date" \| "datetime"}`; enforces types. |
| `index` / `limit` | Primary key (upserts) or rolling row limit. |
| `table_name` | Table name (defaults to the component `id`); saved configs reference it. |
| `url`, `url_format` | Load CSV / JSON / NDJSON / Arrow from a URL. |
| `server_url`, `server_table`, `server_mode` | Connect to a Perspective WebSocket server (`"server"` or `"replicated"`). |
| `update_rows`, `remove_keys` | Streaming: applied every time the prop changes. |
| `config` | Full `ViewerConfigUpdate`; the shortcut props below are merged on top. |
| `plugin`, `columns`, `group_by`, `split_by`, `filter`, `filter_op`, `sort`, `expressions`, `aggregates`, `group_by_depth`, `group_rollup_mode`, `split_rollup_mode`, `plugin_config`, `columns_config`, `theme`, `title`, `settings` | View configuration. |
| `edit_mode` | Datagrid mode: `READ_ONLY`, `EDIT`, `SELECT_ROW`, `SELECT_COLUMN`, `SELECT_REGION`, `SELECT_ROW_TREE`. |
| `workspace` | Multi-panel `WorkspaceConfigUpdate`. |
| `auto_size`, `auto_pause`, `throttle`, `plugin_limits` | Render policy. |

Styling props (`height`, `width`, `border_radius`, ...) apply to the `<perspective-viewer>` element (default height 600px).

## Events

| Event | Payload |
| --- | --- |
| `on_load` | `{"table", "schema", "num_rows", "source"}` |
| `on_config_update` | Full saved viewer config after any change |
| `on_click` | `{"row", "column_names", "config", "panel"}` (`config.filter` selects the clicked datum) |
| `on_select` | `{"selected", "row", "column_names", "insert_filters", "remove_filters", "panel"}` |
| `on_global_filter_update` | List of filters (workspaces) |
| `on_layout_update` / `on_active_panel_update` | Panel ids / active panel id |
| `on_toggle_settings` | `bool` |
| `on_disconnect` / `on_error` | URL / message |

A controlled loop (`config=State.cfg`, `on_config_update=State.set_cfg`) is safe: the
component does not re-apply a config the viewer itself just emitted.

## Actions

All return an `EventSpec`, usable in triggers (`on_click=rp.download("v")`) or returned from
handlers. Arguments may be plain values or state vars. Result-returning actions take a
`callback` event handler.

`save(id, callback)`, `restore(id, config)`, `save_workspace(id, callback)`,
`restore_workspace(id, workspace)`, `reset(id, all=False)`, `toggle_config(id, force=None)`,
`download(id, method="csv")`, `copy(id, method="csv")`, `export(id, callback, method="csv")`,
`update(id, rows)`, `remove(id, keys)`, `replace(id, data)`, `clear(id)`,
`table_size(id, callback)`, `table_schema(id, callback)`, `add_panel(id, config)`, `resize(id)`.

`export` delivers text for `csv`, `json`, `ndjson`, `html` and `json-config`, and a base64
`data:` URL for the binary methods (`arrow*`, and `plugin`, which is a PNG image).

Export methods: `rp.EXPORT_METHODS`. Plugins: `rp.PLUGINS`. Themes: `rp.THEMES`.
Aggregates: `rp.AGGREGATES`.

## Demo app

```bash
uv venv -p 3.12 && uv pip install -e ".[server]"
cd perspective_demo
uv run reflex run
```

| Page | What it shows |
| --- | --- |
| `/` Explorer | Declarative props driven by state controls, click/select/config events |
| `/streaming` | Background task streaming through `update_rows`, `rp.update` bursts, callbacks |
| `/server` | perspective-python tables over a WebSocket: live market feed, 250k-row virtual table, replicated mode, Python-side queries, `authorize` switch and read-only mode |
| `/workspace` | Multi-panel dashboards, master panels, global filters, `saveWorkspace()` |
| `/api` | Imperative actions, named layouts, editable datagrid, mutations, event log |
| `/gallery` | Every chart plugin with a different theme |

Run it with `PERSPECTIVE_DEMO_READ_ONLY=1 uv run reflex run` to serve the WebSocket in
read-only mode: the "Write from the browser" button on `/server` is then refused with 4409.

| | |
| --- | --- |
| ![Streaming](docs/images/streaming.png) | ![Workspace](docs/images/workspace.png) |
| ![Server](docs/images/server.png) | ![Gallery](docs/images/gallery.png) |

## Notes

- Keep `perspective-python` and the npm packages on the same version (`rp.PERSPECTIVE_VERSION`); the WebSocket protocol is versioned together.
- Server tables live in the backend process. With several backend workers each worker has its own hub; use a single worker or an external Perspective server (`server_url="ws://..."`) for shared tables.
- Unless you pass `authorize=` and `read_only=True` (see [Access control](#access-control)), any client that passes the `Origin` check can read **and modify** every hosted table, just like with Perspective's own handlers.
- `update_rows` / `rp.update` write to the viewer's own table. In `server` mode that's the shared hosted table; in `replicated` mode it's only the browser replica (write through the hub to reach every viewer).
- On the very first `reflex run` after installing, Vite may pre-bundle the new npm dependencies while the page loads; reload once if the viewer does not appear.

## Development

```bash
uv sync --extra dev
uv run pytest                      # unit tests
uvx ruff check . && uvx ruff format --check .
uv build                           # sdist + wheel in dist/
```

CI runs on every push and PR to `main` / `develop`: **Quality** (ruff, a JSX syntax check,
pytest on Python 3.10–3.13, a build + `twine check` with a wheel-content check, and a
`reflex compile` of the demo) and **Security** (CodeQL, Bandit, pip-audit, gitleaks and
dependency review).

### Releasing

1. Bump `version` in `pyproject.toml` and `__version__` in
   `custom_components/reflex_perspective/__init__.py` (CI checks they match).
2. Merge to `main`, then tag and push: `git tag v0.1.1 && git push origin v0.1.1`.
3. The **Release** workflow checks the tag against the version, runs the checks again,
   builds, publishes to PyPI (Trusted Publishing) and creates the GitHub release with the
   artifacts attached.

## License

Apache-2.0. Perspective is © the Perspective Authors, Apache-2.0.
