From 55a7b9d0cdfcde4227eb8b47f27c20ac6579dd65 Mon Sep 17 00:00:00 2001 From: Masen Date: Thu, 16 Jul 2026 00:43:32 +0000 Subject: [PATCH 1/5] Reflex integration prototype: chart data over the app websocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the revised docs/design/reflex-integration.md: the xy data plane rides the Reflex app's existing engine.io connection as a second socket.io namespace (/_xy) instead of new HTTP endpoints — binary columns as native attachments (no JSON numbers, no base64, §29), kernel dispatch via xy.channel, lifecycle/auth/proxying inherited from the app socket. python/reflex-xy (new package): - registry.py: per-process figure registry (tokens in state, figures as rebuildable caches, §27), versioning, coalesced fan-out, TTL sweep, append - vars.py: @reflex_xy.figure computed var — evaluation registers the figure, deps auto-track the builder body, token stays stable across rebuilds - state_bridge.py + tokens.py: deterministic xyv1 tokens; registry misses rebuild from Reflex state via state_manager (multi-worker/reconnect story without a figure server or chart data in redis) - namespace.py: sub/unsub/msg protocol, session-affine tokens, fail-closed handlers, mount-addressed replies, room broadcasts - component.py + assets/XYChart.jsx: rx.Component whose React wrapper multiplexes onto the shared socket manager and drives the stock ESM render client (assets/xy_client.js now emitted by js/build.mjs, drift-tested) - app.py: setup(app) + XYPlugin (post_compile) one-line wiring - examples/demo_app: 1M-point drillable scatter, hover rows, box-select cross-filter, streaming line Verification: tests/reflex_adapter (54 tests incl. a real-websocket integration suite over uvicorn) and scripts/reflex_ws_smoke.py, a headless- Chromium E2E that asserts one shared backend websocket, painted pixels on all charts, density-to-points drilldown, the hover pick -> reflex event -> DOM loop, and append streaming — all passing against the demo app. --- CLAUDE.md | 9 + docs/design/reflex-integration.md | 660 +- js/build.mjs | 26 +- pyproject.toml | 9 +- python/reflex-xy/README.md | 81 + python/reflex-xy/examples/demo_app/.gitignore | 6 + python/reflex-xy/examples/demo_app/README.md | 23 + .../examples/demo_app/demo_app/__init__.py | 0 .../examples/demo_app/demo_app/demo_app.py | 187 + .../examples/demo_app/reflex.lock/bun.lock | 705 ++ .../demo_app/reflex.lock/package.json | 35 + .../examples/demo_app/requirements.txt | 2 + .../reflex-xy/examples/demo_app/rxconfig.py | 7 + python/reflex-xy/pyproject.toml | 39 + python/reflex-xy/reflex_xy/__init__.py | 80 + python/reflex-xy/reflex_xy/app.py | 104 + python/reflex-xy/reflex_xy/assets/XYChart.jsx | 228 + python/reflex-xy/reflex_xy/assets/__init__.py | 29 + .../reflex-xy/reflex_xy/assets/xy_client.js | 6066 +++++++++++++++++ python/reflex-xy/reflex_xy/component.py | 78 + python/reflex-xy/reflex_xy/namespace.py | 270 + python/reflex-xy/reflex_xy/registry.py | 280 + python/reflex-xy/reflex_xy/state_bridge.py | 71 + python/reflex-xy/reflex_xy/tokens.py | 88 + python/reflex-xy/reflex_xy/vars.py | 131 + scripts/reflex_ws_smoke.py | 320 + tests/reflex_adapter/__init__.py | 1 + tests/reflex_adapter/conftest.py | 38 + tests/reflex_adapter/test_assets.py | 54 + tests/reflex_adapter/test_component.py | 76 + tests/reflex_adapter/test_figure_var.py | 140 + tests/reflex_adapter/test_registry.py | 148 + .../reflex_adapter/test_socket_data_plane.py | 317 + tests/reflex_adapter/test_state_bridge.py | 91 + tests/reflex_adapter/test_tokens.py | 45 + 35 files changed, 10084 insertions(+), 360 deletions(-) create mode 100644 python/reflex-xy/README.md create mode 100644 python/reflex-xy/examples/demo_app/.gitignore create mode 100644 python/reflex-xy/examples/demo_app/README.md create mode 100644 python/reflex-xy/examples/demo_app/demo_app/__init__.py create mode 100644 python/reflex-xy/examples/demo_app/demo_app/demo_app.py create mode 100644 python/reflex-xy/examples/demo_app/reflex.lock/bun.lock create mode 100644 python/reflex-xy/examples/demo_app/reflex.lock/package.json create mode 100644 python/reflex-xy/examples/demo_app/requirements.txt create mode 100644 python/reflex-xy/examples/demo_app/rxconfig.py create mode 100644 python/reflex-xy/pyproject.toml create mode 100644 python/reflex-xy/reflex_xy/__init__.py create mode 100644 python/reflex-xy/reflex_xy/app.py create mode 100644 python/reflex-xy/reflex_xy/assets/XYChart.jsx create mode 100644 python/reflex-xy/reflex_xy/assets/__init__.py create mode 100644 python/reflex-xy/reflex_xy/assets/xy_client.js create mode 100644 python/reflex-xy/reflex_xy/component.py create mode 100644 python/reflex-xy/reflex_xy/namespace.py create mode 100644 python/reflex-xy/reflex_xy/registry.py create mode 100644 python/reflex-xy/reflex_xy/state_bridge.py create mode 100644 python/reflex-xy/reflex_xy/tokens.py create mode 100644 python/reflex-xy/reflex_xy/vars.py create mode 100644 scripts/reflex_ws_smoke.py create mode 100644 tests/reflex_adapter/__init__.py create mode 100644 tests/reflex_adapter/conftest.py create mode 100644 tests/reflex_adapter/test_assets.py create mode 100644 tests/reflex_adapter/test_component.py create mode 100644 tests/reflex_adapter/test_figure_var.py create mode 100644 tests/reflex_adapter/test_registry.py create mode 100644 tests/reflex_adapter/test_socket_data_plane.py create mode 100644 tests/reflex_adapter/test_state_bridge.py create mode 100644 tests/reflex_adapter/test_tokens.py diff --git a/CLAUDE.md b/CLAUDE.md index bd2303f6..cc075b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,13 @@ 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. Its `assets/xy_client.js` is a build artifact of `js/build.mjs`. + 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 @@ -47,7 +54,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 diff --git a/docs/design/reflex-integration.md b/docs/design/reflex-integration.md index 0910429e..2f819513 100644 --- a/docs/design/reflex-integration.md +++ b/docs/design/reflex-integration.md @@ -1,377 +1,335 @@ # Reflex integration — design -Status: **design** (validated in part by the working prototype in -`examples/reflex/`). The deliverable is an external Reflex adapter -package (working name: `reflex-xy`) that makes a xy figure a -first-class Reflex component with the same performance contract as the notebook -path: screen-bounded binary wire (§29), kernel-side canonical data (§27), -stale-while-revalidate interaction (§17). The adapter dependency budget is -strict: no Reflex dependency if practical, otherwise only a supported -core/component Reflex package unless full Reflex is proven necessary. Full -`reflex` is acceptable for demo apps and user application code, but not as a -default dependency of `xy`, and not as the adapter default unless a -smaller public integration surface cannot work. - -## 1. What the prototype proved, and what it fudged - -`examples/reflex/` (the demo dashboard) bridges with `Figure.to_html()` -iframes plus one hand-rolled `POST /api/xy/drilldown` route serving a -100M-point drilldown chart. It proves the load-bearing claim: **the kernel-side -Figure can serve a browser client over plain HTTP with in-frame-budget -latency** — the client's `ChartView` takes any `comm` object with -`send`/`onMessage`, and a fetch-shim comm works today. - -It also shows exactly what a real integration must fix: - -| Prototype shortcut | Real integration | -|---|---| -| buffers as **base64 inside JSON** (~33% overhead + megabyte JSON strings — against the §29 spirit) | length-prefixed binary responses (`application/octet-stream`) | -| message dispatcher **hand-copied** from `widget.py` | one shared dispatcher in `xy` proper (§3.1) | -| **one global figure** behind a module lock | a session-scoped figure registry (§4) | -| iframe + static HTML file per chart | a real `rx.Component` mounting `ChartView` directly (§5) | - -## 2. The core decision: two planes - -Reflex state sync is JSON diffing over a websocket — excellent for app state, -wrong for data buffers. So the integration splits every chart into: +Status: **prototype landed** (`python/reflex-xy`, tests under +`tests/reflex_adapter/`). This document is the authoritative design; the +prototype implements it end to end over Reflex 0.9.6. The deliverable is an +external adapter package (`reflex-xy`) that makes a xy figure a +first-class Reflex component with the same performance contract as the +notebook path: screen-bounded binary wire (§29), kernel-side canonical data +(§27), stale-while-revalidate interaction (§17). + +Two decisions define this revision (superseding the HTTP-routes draft — see +§8 for the audit trail): + +1. **The data plane rides the app's existing websocket.** No new endpoints; + a second socket.io namespace multiplexes onto the engine.io connection + Reflex already maintains. +2. **There is no figure server and no chart data in Redis.** Figures are + per-process *rebuildable caches*; Reflex state (already durable and + already distributed) is the only source of truth. The figure token is the + rebuild recipe. + +## 1. The core decision: two planes, one socket + +Reflex state sync is JSON diffing over a websocket — excellent for app +state, wrong for data buffers. The integration splits every chart into: - **Control plane (Reflex-native, low-frequency, JSON).** Which figure a - component shows (a token string in `rx.State`), style/layout props, and - *semantic* events out: `on_hover(row_dict)`, `on_select(selection_summary)`. - These go through normal Reflex event handlers so app code composes the - usual way. Row dicts and selection summaries are small by construction — - never data buffers. -- **Data plane (xy-native, high-frequency, binary).** Initial payload, - `density_view`/`view`/`pick`/`select` round-trips, and streaming `append` - pushes, on dedicated backend routes mounted next to the Reflex API. Reflex - state never sees a data byte, so state diffing cost is independent of data - size — the same property that makes the notebook path fast. - -This preserves every dossier invariant without asking Reflex to change: the -figure kernel doesn't know it's inside Reflex, and Reflex doesn't know the -chart ships megabytes. - -## 3. What lands in `xy` (transport-agnostic) - -### 3.1 Factor the message dispatcher out of `widget.py` - -Today the anywidget `_on_custom_msg` inlines the message→handler routing, and -the prototype re-implements it. Extract: + component shows (a token string minted by a computed var), style/layout + props, and *semantic* events out: `on_point_hover(row)`, + `on_select_end(summary)`, `on_view_change(view)`, `on_point_click(row)`. + These go through normal Reflex event handlers, so app code composes the + usual way. Rows and summaries are small by construction — never buffers. +- **Data plane (xy-native, high-frequency, binary).** First paint, + `view`/`density_view`/`pick`/`select` round-trips, streaming `append` + pushes, and full-payload refreshes — on a dedicated socket.io namespace + (`/_xy`) **carried by the same physical websocket** as the control plane. + Reflex state never sees a data byte; state diffing cost is independent of + data size. + +Sharing the connection is the point, not an economy: the data plane inherits +the app connection's lifecycle (connect/reconnect/visibility handling that +Reflex's frontend already implements), its origin/CORS posture, its query +`?token=` identity, and any future connection-level auth — for free, forever, +because it *is* the same connection. Operationally, anything that can proxy +the Reflex app can serve charts; there is no second route to forward, no +per-request HTTP overhead, no SSE keep-alive tuning. + +### Why not three HTTP endpoints (the previous draft) + +`GET /payload` + `POST /msg` + SSE invalidation works — the old prototype +proved it — but each piece costs something the socket gets free: reverse +proxies must be taught each route; every `/msg` pays request setup + headers; +SSE is a second long-lived connection per chart with its own reconnect +logic; and none of it inherits app-plane auth. The §3.2 binary frame format +(XYBF) exists because HTTP bodies need framing — socket.io attachments +already carry length-delimited binary, so on this transport the framing +layer disappears too. XYBF remains in `xy.channel` for HTTP/export +hosts; the namespace does not use it. + +### The cost we accept (recorded, §28 spirit) + +- **Head-of-line blocking.** A multi-megabyte payload frame shares the TCP + stream with state deltas; on slow links a full refresh can delay an app + event behind it. Payloads are screen-bounded (§29) so the practical size + is single-digit MB; if it ever matters, chunked payload emission (bounded + frames interleaved with other traffic) fits behind the same events without + protocol change. +- **Version coupling.** The wrapper mirrors Reflex's socket options + (`transports`, ws subprotocol, `?token=` query) so the manager cache + merges the connections. Those names are pinned by + `tests/reflex_adapter/test_assets.py` — a Reflex upgrade that renames + them fails loudly in CI, not silently in prod. +- **One engine.io connection per tab** stays the invariant. If a chart page + somehow loads without state enabled there is no socket at all — but + figure tokens come from state, so that page has no charts either. + +## 2. Transport: a second namespace on the app's socket + +**Backend.** Reflex builds a python-socketio `AsyncServer` at app +construction and registers its `/_event` namespace on it. The adapter +registers one more namespace on the same server: ```python -# xy/channel.py -def handle_message(fig: Figure, content: dict) -> tuple[dict, list[bytes]] | None: - """One kernel-side dispatcher for every transport: anywidget, Reflex - routes, and anything else. Returns (reply_message, buffers) or None - for malformed/ignorable input. Never raises on client-supplied data.""" +app.sio.register_namespace(XYNamespace(registry, rebuild=...)) # "/_xy" ``` -`FigureWidget` becomes a thin wrapper (comm in → `handle_message` → comm out), -the Reflex endpoint another. Third copies are how protocols drift. - -### 3.2 Wire framing for HTTP - -One binary response format shared by payload fetch and message replies: +A namespace is a socket.io protocol concept, not a URL: no route, no mount, +no proxy entry. Wiring is one line in `rxconfig.py` — +`plugins=[reflex_xy.XYPlugin()]` — whose `post_compile` hook receives the +live `App` at backend-worker startup (after the socket server exists, before +any client connects), or an explicit `reflex_xy.setup(app)` for people who +prefer it in `app.py`. A lifespan task captures the serving loop (for +thread-safe fan-out from sync handlers) and runs the registry TTL sweep. + +**Frontend.** socket.io-client caches managers by +`(protocol, host, port, engine.io path)`. The wrapper connects to namespace +`/_xy` with the *same* URL and options Reflex's `connect()` uses +(`getBackendURL(env.EVENT)`, `path: endpoint.pathname`, +`transports: [env.TRANSPORT]`, `protocols: [version]`, +`query: {token: getToken()}`) — so whichever side connects first creates the +manager and the other multiplexes onto it. One websocket in the browser's +network tab, two namespaces inside it. React effect ordering means the chart +often connects first; mirroring the options exactly is what makes that safe +(the backend sees an identical connection either way). + +Reflex owns reconnection: its `reconnect()` reopens the shared manager, our +namespace socket re-CONNECTs automatically, and every mounted chart re-`sub`s +on the `connect` event. + +**Wire shape.** Metadata is one small JSON object per event; every data +column is a `bytes` value inside it, which python-socketio hoists into +binary attachments and the browser receives as `ArrayBuffer`s *in place* — +aligned, zero-copy into `Float32Array`s. No JSON numbers for data, no +base64, no custom framing (§29 preserved; the socket.io protocol already +length-prefixes attachments). ``` -offset type field -0 char[4] magic = "XYBF" -4 u8 frame_version = 1 -5 u8 flags = 0 -6 u16 header_size = 24 -8 u32 metadata_length -12 u32 buffer_count -16 u64 total_frame_length -24 bytes strict UTF-8 JSON metadata object - padding zeroes to the next 8-byte boundary -repeat buffer_count times: - u64 buffer_length - bytes buffer (starts at an 8-byte boundary) - padding zeroes to the next 8-byte boundary +client -> server (namespace /_xy) + sub {fig, px?, mid} subscribe; join figure room; reply `payload` + unsub {fig, mid} leave the room + msg {fig, mid, m} one xy.channel.handle_message dispatch + +server -> client + payload {fig, version, spec, buffers} first paint / full refresh + msg {fig, mid?, message, buffers} reply (mid echoed) or push (no mid) + err {fig, error} unknown/foreign token, rebuild failed ``` -Little-endian, `application/octet-stream`. Transport-frame versioning is -separate from the renderer protocol in the spec: either layer can evolve and -fail loudly without coupling the two. Version 1 has no flags; unknown -versions, flags, header sizes, non-zero padding, length mismatches, invalid -UTF-8/JSON, truncation, and trailing bytes all fail closed. - -The default decoder caps one frame at 512 MiB, metadata at 8 MiB, 4096 buffers, -and one buffer at 256 MiB. Adapters should normally set smaller -application-specific limits and **must reject an oversized `Content-Length` -before reading the request body**. HTTP `Content-Encoding` owns gzip/Brotli; -compression is not duplicated in the frame flags. - -Python exposes `encode_frame_parts()` (scatter/gather segments; input buffers -are not copied), `encode_frame()` (one final owned-body assembly), and -`decode_frame()` (buffer `memoryview`s into the received body) from -`xy.channel`. The shipped ESM/IIFE client exports `decodeFrame()`, which returns -aligned `Uint8Array` spans into `Response.arrayBuffer()`. `ChartView` accepts -those spans directly, including a non-zero aligned payload base offset, rather -than slicing the frame. Legacy unaligned anywidget views retain a one-copy -compatibility fallback. The existing `(message, buffers[])` client contract stays -unchanged: no JSON numbers for data, no base64 inflation, and no giant-string -parse on the main thread. - -## 4. What lands in the Reflex adapter package - -Dependency direction: adapter package → `xy`; `xy` itself stays -Reflex-free (existing CLAUDE.md rule; also why `components.py` — the -Reflex-flavored composition API — imports nothing). - -The adapter must use the smallest supported Reflex dependency surface: - -- Required for `xy`: no Reflex dependency of any kind. -- Best for the adapter: no hard Reflex dependency; expose registry/data-plane helpers and a - component declaration that works when a Reflex app already has Reflex - installed. -- Good: depend only on a supported Reflex core/component package if Reflex - publishes one. -- Last resort: depend on full `reflex`, with the reason documented and isolated - to an explicit adapter extra or app package. Full Reflex must never become a - transitive dependency of `xy`, and should not be the default - `reflex-xy` install unless there is no supported smaller API. - -### 4.1 Figure registry - -Figures must NOT live in `rx.State`: state is serialized per event -(pickled to Redis in prod), and canonical columns can be gigabytes. Instead: - -```python -token = rfc.register(fig) # uuid string; THIS goes in state -rfc.figure(token) # kernel-side lookup -rfc.release(token) # explicit; plus TTL sweep as backstop -``` +`mid` is a per-mount id: several charts on a page share the socket, replies +are mount-addressed, pushes are room-wide. The kernel dispatch is byte-for- +byte the notebook dispatch — `xy.channel.handle_message` (§3.1 of the +old draft, now shipped), run off the event loop via a worker thread (the +Rust kernels release the GIL) under a per-figure lock. -Registry entries: `{token: (figure, version, lock, last_access)}`. `version` -bumps on any mutation (`append`, filter rebuilds); the client sends its -version so replies can say "stale, refetch". Per-figure locks serialize -kernel calls (the kernels release the GIL in Rust, so concurrent figures -still parallelize). +Inbound handlers are total: malformed input drops or answers `err`, never +raises — `channel.py`'s "hostile client must not crash the kernel" contract +extended to the transport. -**The honest hard problem — multi-worker deployment.** The registry is -process-local; Reflex prod can run several backend workers. v0 ships with -that documented: single worker, or sticky routing by token. The clean later -fix is making the data plane a separate single process (a "figure server") -that all workers talk to — the registry API above is already the seam for it. -Do not silently pretend this away; it's the §28 rule applied to deployment. +## 3. Figures: registry as cache, state as truth -### 4.2 Backend routes +### 3.1 The figure var (the pattern that sidesteps the distributed problem) -Mounted on Reflex's FastAPI app by `rfc.setup(app)` (the prototype proves -`app._api.add_route` works; use the public `api_transformer` hook where -available): +```python +class Dash(rx.State): + points: int = 1_000_000 + @reflex_xy.figure + def cloud(self) -> fc.Chart: + x, y, mag = load(self.points) + return fc.scatter_chart(fc.scatter(x, y, color=mag), width="100%", height=460) ``` -GET /_xy/{token}/payload → framed spec+blob (ETag: version) -POST /_xy/{token}/msg → handle_message() (framed reply) -GET /_xy/{token}/events → SSE stream of version/invalidation notices -``` - -`payload` is cacheable by version — a re-render after an unrelated state -change costs a 304, not a reship. `events` is a text-only invalidation plane: -a version notice makes the client fetch the binary append/snapshot from a data -route. Never base64-wrap an append to force it through SSE. If pushed binary -becomes necessary, use a WebSocket; the `comm` abstraction and frame/message -contracts do not change. -### 4.3 The component +`@reflex_xy.figure` is a computed var whose **value is only the token +string** — `xyv1|||` — and whose +evaluation is what (re)registers the figure in the per-process registry. +Reflex's own dependency tracker watches the *builder's* body (the var +subclass points dependency analysis at it), so: + +- First render: var evaluates → figure built and registered → token into + state. +- A dependency changes: Reflex marks the var dirty; the next delta + evaluation rebuilds the figure and re-publishes; every subscriber gets a + fresh payload pushed over the data plane. The token is deterministic, so + the frontend sees **no prop change at all** — pixels move, DOM doesn't. +- Reconnect (same node or another): the cached token comes back with the + state; the component re-`sub`s; hit → serve, miss → §3.2. + +Builders must be pure functions of their state instance — the discipline +cached computed vars already impose — because purity is exactly what makes +the figure a *rebuildable cache* instead of precious process state. This is +§27 applied to processes: canonical data is Reflex state; every registered +figure is a derived buffer. + +### 3.2 Registry miss: rebuild from state + +`sub` (or `msg`) on an unknown state token parses it, resolves the state +class from the full name, loads that session's state through +`app.state_manager.get_state(BaseStateToken(...))` — memory, disk, or Redis, +whatever the app configured — finds the builder on the var, re-runs it, and +serves. The worker that answers a reconnect never needs to have seen the +figure before. **Reflex prod-mode multi-worker works without a figure +server, sticky routing, or chart data in Redis** — the state that was going +to be in Redis anyway is the recovery record. + +Failure stays closed: unparseable tokens, unknown states/vars, or builders +that raise all answer `err {fig, error}`; the client logs and shows an empty +mount rather than crashing the page. + +### 3.3 Access control + +The connection's `?token=` (the Reflex client token) is captured at +namespace connect. A state token embeds the client token it was minted for, +and `sub`/`msg` refuse a figure whose embedded client token differs from the +connection's (`err: figure belongs to another session`). Tokens carry +nothing their own client doesn't already know. When Reflex grows real +connection auth, it lands on this same connection and the data plane +inherits it (§1). + +One deliberate consequence of rebuild-from-state: subscribing to a +never-registered token of your *own* session materializes a default-state +figure — indistinguishable from loading the page fresh, and gated by the +same affinity check. + +### 3.4 Imperative tier + +`reflex_xy.register(chart) -> "xyfig-"` / `release(token)` keep the +old draft's explicit API for figures that aren't state-derived (ad-hoc +exploration, tests). Opaque tokens rely on unguessability (same trust model +as the client token itself), are **not** rebuildable, and die with the +process or the TTL sweep — documented as the dev tier, not deployment-safe. + +### 3.5 Lifecycle + +Rooms track subscriptions; disconnects clean rooms, never figures (a page +reload must not destroy what its reconnect will re-request). The TTL sweep +(30 min idle, lifespan task) bounds leaked figures; state-derived figures +transparently rebuild after a sweep, so the TTL is a memory bound, not a +correctness bound. Rapid re-publishes coalesce: an un-started broadcast +absorbs newer publishes and always ships the latest payload. + +## 4. Updates and streaming + +- **State-driven rebuild** (filter changed): the figure var recomputes, + `registry.publish` bumps the version and pushes one full `payload` to the + room. Stable token: no component re-render, one screen-bounded reship. +- **Streaming**: `reflex_xy.append(token, x=..., y=...)` from any handler, + background task, or thread → `Figure.append` under the figure lock (worker + thread) → the same `append` message the notebook widget ships, pushed + room-wide as a `msg` event. The client applies it with the existing follow + policy (refit at home, slide when pinned to the live edge, hold when + inspecting history). +- **Interaction** (pan/zoom/hover/select): `msg` round-trips into the + kernel, exactly the anywidget flow — tier updates, density re-bins, exact + f64 pick rows, selection masks as binary buffers. + +## 5. The component ```python -import reflex_xy as rfc - -class Dash(rx.State): - chart: str = "" # figure token — the ONLY chart state - - def load(self): - chart = fc.scatter_chart(fc.scatter(x, y, color=c)) - self.chart = rfc.register(chart) - - def picked(self, row: dict): ... - def selected(self, sel: dict): ... - -def index(): - return rfc.chart( - token=Dash.chart, - on_hover=Dash.picked, # semantic events → normal handlers - on_select=Dash.selected, - width="100%", height="480px", - ) +reflex_xy.chart( + Dash.cloud, # the figure var (or a register() token) + on_point_hover=Dash.on_hover, # semantic events -> normal handlers + on_select_end=Dash.on_select, + height="460px", +) ``` -`rfc.chart` is the thinnest Reflex-compatible component wrapper possible. If -Reflex exposes a core component API, use that instead of importing the full app -framework. Its React wrapper: (1) fetches the framed payload for `token`, (2) -instantiates `ChartView(el, spec, blob, comm)` with a fetch/SSE comm adapter -(the prototype's shim, productionized), (3) forwards `pick_result`/`selection` -replies into the Reflex event dispatcher as plain JSON, (4) destroys the view -and fires a release beacon on unmount. The JS client is the same committed ESM -bundle the wheel ships — one renderer for notebooks, static export, and Reflex. - -### 4.4 State-driven updates and streaming - -- **App-driven rebuild** (filter changed): the handler mutates the figure via - the registry (or registers a fresh one), version bumps, and either the - token prop change or an SSE version ping makes the component refetch. - Cost: one screen-bounded payload. -- **Streaming**: `rfc.append(token, x=..., y=...)` from a background task → - `Figure.append` → version invalidation on `/events` → binary fetch of the - append/snapshot. The client applies it with the existing follow policy - (refit at home, slide when pinned to the live edge, hold when inspecting - history). A later WebSocket may combine invalidation and binary delivery. - -## 5. Latency budget (why this matches the notebook path) - -Same-host POST round-trip ~1–3 ms + kernel view compute 1.5–12 ms (pyramid / -exact re-bin at 10M) lands inside the client's 120 ms request debounce and -under typical frame-budget perception either way. Hover uses client-side GPU -picking with only the row readout crossing the wire, already throttled. The -transport differences vs anywidget (HTTP vs Jupyter comm) are noise against -the compute; SSE replaces comm push. If hover/pick volume ever argues for it, -the data plane can switch POST→WebSocket without touching the message -protocol — `comm` is already the abstraction seam. - -## 6. What an example app looks like (target DX) - -A fleet-telemetry dashboard: 10M GPS pings as a drillable density scatter, -box-select cross-filtering a latency histogram, and a live throughput line -fed by a background task. This is the acceptance bar for the whole design — -**aspirational code**, written against the API of §4, not runnable yet. +`chart()` is a plain `rx.Component` whose `library` is a **local JSX shared +asset** (`$/public/external/reflex_xy/assets/XYChart.jsx`, the same +mechanism reflex's own radix color-mode provider uses) — no npm package, no +CDN. Beside it ships `xy_client.js`, a byte-exact copy of the wheel's +ESM render client (`node js/build.mjs` emits both; a parity test fails on +drift): one renderer for notebooks, static export, and Reflex. + +The wrapper: opens/reuses the shared namespace socket, `sub`s with the +element's measured width, builds a `ChartView` per `payload` (full refresh = +destroy + rebuild), bridges `comm` to `msg` events, and forwards semantic +events into Reflex's event system via the component's event-trigger props +(`props.onPointHover(row)` → `addEvents(...)` → the user's handler). +Client-side niceties: `view_change` resolves locally (no kernel round-trip; +the namespace registers no Python callbacks), `click` issues a tagged `pick` +so `on_point_click` delivers the exact row, `selection` replies pair with +the brush rect that produced them. + +Multiple mounts of one figure render and stream correctly (room fan-out, +`mid`-addressed replies); concurrent *drilldown* from several views of the +same figure shares kernel drill state — same known engine-level shape as +multiple notebook views today, acceptable and documented. + +## 6. Latency budget + +Unchanged from the notebook comparison, minus HTTP: an interaction message +is one ws frame each way (~0.1–1 ms same-host) around the same kernel +compute (1.5–12 ms view/re-bin at 10M, §12 numbers), inside the client's +120 ms request debounce. Hover stays client-side GPU picking with a +row-readout reply. Appends are push, so streaming latency is producer-bound, +not poll-bound. The figure-var rebuild path adds builder time on state +changes — builders are user code and should be O(state); heavy shared data +prep belongs outside the builder (module cache / backend var), which the +demo app models. + +## 7. What shipped where (prototype map) -```python -import numpy as np -import reflex as rx -import xy as fc -import reflex_xy as rfc - - -def load_pings() -> dict[str, np.ndarray]: - ... # 10M rows: lon, lat, latency_ms — parquet via pyarrow, zero-copy ingest - - -class Telemetry(rx.State): - # Figure TOKENS are the only chart state — strings, cheap to diff. - # The figures themselves (80+ MB of canonical columns) live in the - # kernel-side registry, never in rx.State. - map_chart: str = "" - latency_chart: str = "" - live_chart: str = "" - - hovered: dict = {} # row under the cursor (semantic, small) - selected_count: int = 0 - streaming: bool = False - - @rx.event - def load(self): - pings = load_pings() - self._pings = pings # backend-only var: raw arrays for cross-filter - - # 10M points: ships as a density surface, drills to real points on - # zoom, hover reads exact f64 rows — all default behavior. - chart = fc.scatter_chart( - fc.scatter(pings["lon"], pings["lat"], color=pings["latency_ms"]) - ) - self.map_chart = rfc.register(chart) - - self.latency_chart = rfc.register( - fc.histogram_chart(fc.histogram(pings["latency_ms"], bins=120)) - ) - - self.live_chart = rfc.register( - fc.line_chart(fc.line(np.array([0.0]), np.array([0.0]))) - ) - - @rx.event - def on_map_hover(self, row: dict): - self.hovered = row # {'x': lon, 'y': lat, 'color_value': latency…} - - @rx.event - def on_map_select(self, selection: dict): - # Box-select on 10M points → cross-filter the histogram. The - # selection payload carries indices (capped) + count; the handler - # rebuilds the small figure and swaps the token. One screen-bounded - # refetch on the client; rx.State never sees a data buffer. - idx = rfc.selection_indices(selection) # np.ndarray[u32] - self.selected_count = int(len(idx)) - rfc.release(self.latency_chart) - self.latency_chart = rfc.register( - fc.histogram_chart(fc.histogram(self._pings["latency_ms"][idx], bins=120)) - ) - - @rx.event(background=True) - async def stream(self): - async with self: - self.streaming = True - t = 0.0 - while self.streaming: - t += 1.0 - # append → version bump → SSE push → client applies the same - # `append` message the notebook widget sends (follow policy: - # refit at home / slide when pinned to the live edge). - rfc.append(self.live_chart, x=[t], y=[throughput_sample()]) - await asyncio.sleep(0.25) - - @rx.event - def cleanup(self): - for token in (self.map_chart, self.latency_chart, self.live_chart): - rfc.release(token) - - -def index() -> rx.Component: - return rx.vstack( - rx.hstack( - rfc.chart( # 10M-point drillable map - token=Telemetry.map_chart, - on_hover=Telemetry.on_map_hover, - on_select=Telemetry.on_map_select, - width="100%", height="520px", - ), - rx.vstack( - rfc.chart(token=Telemetry.latency_chart, height="250px"), - rfc.chart(token=Telemetry.live_chart, height="250px"), - rx.text(f"{Telemetry.selected_count:,} pings selected"), - rx.button("Go live", on_click=Telemetry.stream), - ), - ), - on_mount=Telemetry.load, - on_unmount=Telemetry.cleanup, - ) - - -app = rx.App() -rfc.setup(app) # mounts /_xy/* routes -app.add_page(index) +``` +python/reflex-xy/ + reflex_xy/registry.py token -> FigureEntry(figure, version, lock); TTL; + publish/push fan-out seams; append + reflex_xy/tokens.py xyv1 token grammar; builder discovery on vars + reflex_xy/vars.py @reflex_xy.figure (FigureVar: builder-tracked deps) + reflex_xy/state_bridge.py token -> state_manager -> builder rebuild hook + reflex_xy/namespace.py XYNamespace: sub/unsub/msg, payload/msg/err, + affinity, rebuild-on-miss, binary attachments + reflex_xy/app.py setup(app), XYPlugin (post_compile), lifespan + reflex_xy/component.py chart() -> rx.Component (local-JSX library) + reflex_xy/assets/ XYChart.jsx + xy_client.js (build artifact) + examples/demo_app/ 1M-point drilldown + hover + cross-filter + stream +tests/reflex_adapter/ 54+ tests: token/registry/var/bridge units, + component compile, and a real-websocket + integration suite (uvicorn + socketio client) + covering payload/pick/select/affinity/rebuild/ + publish-broadcast/append/unsub ``` -What this example is designed to prove, line by line: - -- **State stays tiny.** Three token strings, a hovered row, a count, a flag. - The 10M-point figure never crosses the state serializer; `_pings` is a - backend-only var (never synced). -- **Interaction costs what the notebook costs.** Pan/zoom on the map goes - component → `/msg` → pyramid/re-bin → framed binary back. No Reflex event - round-trip, no state diff, no re-render of anything else on the page. -- **Cross-filtering is just Python.** `on_map_select` receives a semantic - selection, slices NumPy, registers a fresh small figure, swaps the token. - The component sees a new token prop and refetches one screen-bounded - payload. No special "linked charts" machinery to learn. -- **Streaming is one call in a background task.** `rfc.append` reuses the - whole Phase-0 streaming stack; the client-side follow policy makes the - live line march without any frontend code. -- **Lifecycle is visible.** `register`/`release` bracket the figures; - unmount cleans up; the TTL sweep catches whatever a crashed tab leaks. - -## 7. Build order - -1. `xy/channel.py`: extract `handle_message` from `widget.py`; add - the binary framing helpers + tests (no behavior change to the widget). -2. External adapter package: registry + routes + framed-wire JS comm adapter; - use no Reflex dependency, or only a supported Reflex core/component - dependency, unless full Reflex is proven necessary. Port the prototype's - drilldown page onto it (deleting the base64/global-figure shortcuts) as the - acceptance test. -3. The `rfc.chart` component + semantic event forwarding; demo app switches - from iframes to components. -4. SSE invalidation + binary append fetch (or WebSocket binary push) for - `rfc.append`; wire the streaming demo without base64. -5. Multi-worker story (document first, figure-server later). +`xy` itself stays Reflex-free (CLAUDE.md rule); the adapter depends on +`xy` + full `reflex` for now — the 0.9.6 `reflex-base` split covers +components/vars but not yet App/state-manager access; revisit when a smaller +supported surface exists. + +## 8. Superseded: the HTTP-routes draft + +The previous revision of this document specified `GET /_xy/{token}/payload`, +`POST /_xy/{token}/msg`, an SSE `/events` invalidation stream, and the XYBF +binary frame (§3.2). What survives: `handle_message` extraction (shipped as +`xy.channel`), the XYBF frame helpers (still in `xy.channel` for +HTTP/export hosts), the registry API shape, and the two-planes analysis. What +changed: transport (§1–§2) and the multi-worker story — the old draft called +the registry's process-locality "the honest hard problem" and sketched a +figure-server; the figure-var + rebuild-from-state design (§3) dissolves it +instead of centralizing it. A future host that genuinely needs HTTP (static +export drilldown, non-Reflex embedding) picks the frame helpers back up; the +message protocol is transport-agnostic either way. + +## 9. Open items (tracked, §28: nothing silent) + +- **Payload push sizing**: room-wide refreshes use the figure's default + `px_width`; per-sid re-fit to each viewport is a straightforward follow-up. +- **Chunked payload emission** if head-of-line blocking ever shows up in + traces (§1). +- **Server-side event dispatch** (kernel callbacks → `app.event_processor`) + would save the client hop for hover-driven state updates; measure first. +- **reflex-base-only dependency** once App/state-manager surfaces land there. +- **Browser E2E in CI**: `scripts/reflex_ws_smoke.py` asserts the + one-websocket invariant, painted pixels, density→points drilldown, the + hover event loop, and append streaming against the running demo app + (stdlib CDP driver, no new deps). Runs locally today; needs a CI story + (bun + vite in the runner). diff --git a/js/build.mjs b/js/build.mjs index 2cd7db83..bc984b86 100644 --- a/js/build.mjs +++ b/js/build.mjs @@ -225,15 +225,23 @@ 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 ships the identical ESM client as a bundler-visible +// shared asset (docs/design/reflex-integration.md §5): one renderer for +// notebooks, static export, and Reflex. Emitted here so the copies can +// never drift — the --check mode and tests/reflex_xy/test_assets.py both +// fail on a stale copy. +const reflexAssetsDir = join(here, "..", "python", "reflex-xy", "reflex_xy", "assets"); +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], + [reflexAssetsDir, "xy_client.js", esm], ]; 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); @@ -245,13 +253,15 @@ if (checkOnly) { } if (stale.length) { console.error( - `static JS bundle check failed: ${stale.join(", ")}. Run \`node js/build.mjs\` and commit python/xy/static/*.js.` + `static JS bundle check failed: ${stale.join(", ")}. Run \`node js/build.mjs\` and commit python/xy/static/*.js + python/reflex-xy/reflex_xy/assets/xy_client.js.` ); process.exit(1); } 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); - console.log(`built static/index.js and static/standalone.js from ${PARTS.length} parts`); + for (const [dir, name, data] of outputs) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), data); + } + console.log(`built static/index.js, static/standalone.js, and reflex_xy assets from ${PARTS.length} parts`); } diff --git a/pyproject.toml b/pyproject.toml index 50834388..33e62c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,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"] @@ -146,8 +150,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"] diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md new file mode 100644 index 00000000..73c7946e --- /dev/null +++ b/python/reflex-xy/README.md @@ -0,0 +1,81 @@ +# 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 as fc +import reflex_xy + + +class Dash(rx.State): + points: int = 200_000 + hovered: dict = {} + + @reflex_xy.figure + def chart(self) -> fc.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 fc.scatter_chart(fc.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. + +Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or +background task pushes an incremental update over the same socket. + +## Demo + +`examples/demo_app/` in this directory is a runnable dashboard (drilldown +scatter, hover readout, box-select cross-filter, live streaming line). diff --git a/python/reflex-xy/examples/demo_app/.gitignore b/python/reflex-xy/examples/demo_app/.gitignore new file mode 100644 index 00000000..b53b5cd0 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/.gitignore @@ -0,0 +1,6 @@ +.states +assets/external/ +.web +*.db +__pycache__/ +*.py[cod] diff --git a/python/reflex-xy/examples/demo_app/README.md b/python/reflex-xy/examples/demo_app/README.md new file mode 100644 index 00000000..72174040 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/README.md @@ -0,0 +1,23 @@ +# reflex-xy demo + +One page exercising the whole integration: a 1M-point drillable density +scatter, hover row readout, box-select cross-filtering a histogram, and a +live streaming line — all chart data on the app's own websocket. + +```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 +``` diff --git a/python/reflex-xy/examples/demo_app/demo_app/__init__.py b/python/reflex-xy/examples/demo_app/demo_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py new file mode 100644 index 00000000..d33beab3 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py @@ -0,0 +1,187 @@ +"""reflex-xy demo: one page, every integration surface. + +- 1M-point drillable scatter (density tier -> exact points on zoom), + defined by a `@reflex_xy.figure` state method. Data buffers never touch + Reflex state; the only chart state is the token string. +- Hover reads exact f64 rows through the data plane and lands in a normal + Reflex event handler. +- Box-select cross-filters a histogram: the selection summary arrives as a + small JSON event, the handler bumps a state var, and the histogram's + figure var recomputes + republishes over the shared websocket. +- A live line streams from a background task via `reflex_xy.append`. + +Run: uv pip install -e '.[dev]' && uv pip install -e python/reflex-xy + cd python/reflex-xy/examples/demo_app && reflex run +""" + +from __future__ import annotations + +import asyncio +from functools import lru_cache + +import numpy as np +import reflex as rx +import reflex_xy + +import xy as fc + +POINTS = 1_000_000 +RNG_SEED = 11 + + +@lru_cache(maxsize=1) +def _cloud(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + # Deterministic source data, cached at module scope: figure builders are + # pure functions of state, so shared raw columns belong outside them. + rng = np.random.default_rng(RNG_SEED) + x = rng.normal(0.0, 1.0, n) + y = x * 0.55 + rng.normal(0.0, 0.55, n) + return x, y, np.hypot(x, y) + + +class Demo(rx.State): + """Charts are figure vars; everything else is ordinary app state.""" + + hovered: dict = {} + select_note: str = "box-select on the scatter to cross-filter" + sel_x0: float = 0.0 + sel_x1: float = 0.0 + sel_active: bool = False + streaming: bool = False + _stream_t: float = 0.0 + + @reflex_xy.figure + def cloud(self) -> fc.Chart: + x, y, mag = _cloud(POINTS) + return fc.scatter_chart( + fc.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), + fc.x_axis(label="feature A"), + fc.y_axis(label="feature B"), + title=f"{POINTS // 1_000_000}M points, drillable", + width="100%", + height=460, + ) + + @reflex_xy.figure + def histogram(self) -> fc.Chart: + x, _, mag = _cloud(POINTS) + if self.sel_active and self.sel_x1 > self.sel_x0: + mag = mag[(x >= self.sel_x0) & (x <= self.sel_x1)] + label = "selection" if self.sel_active else "all points" + return fc.histogram_chart( + fc.histogram(mag, bins=80), + fc.x_axis(label=f"magnitude ({label})"), + title="magnitude distribution", + width="100%", + height=220, + ) + + @reflex_xy.figure + def live(self) -> fc.Chart: + return fc.line_chart( + fc.line(np.array([0.0]), np.array([0.0])), + title="live stream", + width="100%", + height=220, + ) + + @rx.event + def on_hover(self, row: dict): + self.hovered = row + + @rx.event + def on_select(self, selection: dict): + total = int(selection.get("total") or 0) + if total and selection.get("x0") is not None: + self.sel_x0 = float(selection["x0"]) + self.sel_x1 = float(selection["x1"]) + self.sel_active = True + self.select_note = f"{total:,} points selected" + else: + self.sel_active = False + self.select_note = "selection cleared" + + @rx.event(background=True) + async def stream(self): + async with self: + if self.streaming: + self.streaming = False + return + self.streaming = True + token = self.live + while True: + async with self: + if not self.streaming or token != self.live: + break + self._stream_t += 1.0 + t = self._stream_t + reflex_xy.append( + token, + x=[t], + y=[float(np.sin(t / 9.0) * 4.0 + np.random.default_rng(int(t)).normal(0, 0.4))], + ) + await asyncio.sleep(0.25) + + +def hover_readout() -> rx.Component: + return rx.hstack( + rx.badge("hover"), + rx.text( + rx.cond( + Demo.hovered.length() > 0, + f"x={Demo.hovered['x']} y={Demo.hovered['y']}", + "move the cursor over the cloud", + ), + font_family="monospace", + font_size="13px", + ), + spacing="3", + align="center", + ) + + +def index() -> rx.Component: + return rx.container( + rx.vstack( + rx.heading("xy × reflex", size="6"), + rx.text( + "chart data rides the app websocket — no extra endpoints, " + "no JSON numbers, kernel-side drilldown", + color_scheme="gray", + size="2", + ), + reflex_xy.chart( + Demo.cloud, + on_point_hover=Demo.on_hover, + on_select_end=Demo.on_select, + height="460px", + id="cloud", + ), + hover_readout(), + rx.hstack( + rx.vstack( + reflex_xy.chart(Demo.histogram, height="220px", id="hist"), + rx.text(Demo.select_note, size="2", color_scheme="gray"), + width="50%", + ), + rx.vstack( + reflex_xy.chart(Demo.live, height="220px", id="live"), + rx.button( + rx.cond(Demo.streaming, "stop stream", "go live"), + on_click=Demo.stream, + id="stream-btn", + ), + width="50%", + ), + width="100%", + ), + spacing="4", + width="100%", + ), + size="4", + padding_y="24px", + ) + + +app = rx.App() +app.add_page(index, title="reflex-xy demo") diff --git a/python/reflex-xy/examples/demo_app/reflex.lock/bun.lock b/python/reflex-xy/examples/demo_app/reflex.lock/bun.lock new file mode 100644 index 00000000..2b596d2d --- /dev/null +++ b/python/reflex-xy/examples/demo_app/reflex.lock/bun.lock @@ -0,0 +1,705 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "reflex", + "dependencies": { + "@radix-ui/themes": "3.3.0", + "@react-router/node": "7.15.0", + "isbot": "5.1.40", + "lucide-react": "1.14.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-error-boundary": "6.1.1", + "react-helmet": "6.1.0", + "react-router": "7.15.0", + "react-router-dom": "7.15.0", + "socket.io-client": "4.8.3", + "sonner": "2.0.7", + "universal-cookie": "7.2.2", + }, + "devDependencies": { + "@emotion/react": "11.14.0", + "@react-router/dev": "7.15.0", + "@react-router/fs-routes": "7.15.0", + "autoprefixer": "10.5.0", + "postcss": "8.5.14", + "postcss-import": "16.1.1", + "vite": "8.0.16", + }, + }, + }, + "overrides": { + "cookie": "1.1.1", + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + + "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.11", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collapsible": "1.1.16", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dialog": "1.1.19", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.2", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-label": "2.1.11", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.12", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.14", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-toggle": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-separator": "1.1.11", "@radix-ui/react-toggle-group": "1.1.15" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.3", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + + "@radix-ui/themes": ["@radix-ui/themes@3.3.0", "", { "dependencies": { "@radix-ui/colors": "^3.0.0", "classnames": "^2.3.2", "radix-ui": "^1.1.3", "react-remove-scroll-bar": "^2.3.8" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw=="], + + "@react-router/dev": ["@react-router/dev@7.15.0", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@react-router/node": "7.15.0", "@remix-run/node-fetch-server": "^0.13.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "pkg-types": "^2.3.0", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.2.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.15.0", "@vitejs/plugin-rsc": "~0.5.21", "react-router": "^7.15.0", "react-server-dom-webpack": "^19.2.3", "typescript": "^5.1.0 || ^6.0.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "react-server-dom-webpack", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-ZwUQu4KNZrViFqdeFWqh00Bk/QbLNvoWRDfjsqOp3oyuG3jSRLYnqRD3VAMK/FYMpL+s37ByT7XqqLXaF7Nw1g=="], + + "@react-router/fs-routes": ["@react-router/fs-routes@7.15.0", "", { "dependencies": { "minimatch": "^9.0.0" }, "peerDependencies": { "@react-router/dev": "^7.15.0", "typescript": "^5.1.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Bn0PvYQCpFm547UBc2hP6bI9Rv6i5ewHiVG+hZsPxkYH/txvTWwwF7b4lle7TaO/UeHA7J5rhlB2a8C4ubXO7w=="], + + "@react-router/node": ["@react-router/node@7.15.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.15.0", "typescript": "^5.1.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-SgvWaWF1n3u+bpXXZUW9BSd2p/NwkIYLz4SSeDYqoX5RkYX5rcI4cHHuNJXszPu+Dm9QIri4J9g/4EV3KfgiXQ=="], + + "@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.13.3", "", {}, "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.392", "", {}, "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g=="], + + "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + + "postcss-import": ["postcss-import@16.1.1", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "radix-ui": ["radix-ui@1.6.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-accessible-icon": "1.1.11", "@radix-ui/react-accordion": "1.2.16", "@radix-ui/react-alert-dialog": "1.1.19", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-aspect-ratio": "1.1.11", "@radix-ui/react-avatar": "1.2.2", "@radix-ui/react-checkbox": "1.3.7", "@radix-ui/react-collapsible": "1.1.16", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-context-menu": "2.3.3", "@radix-ui/react-dialog": "1.1.19", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.20", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-form": "0.1.12", "@radix-ui/react-hover-card": "1.1.19", "@radix-ui/react-label": "2.1.11", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-menubar": "1.1.20", "@radix-ui/react-navigation-menu": "1.2.18", "@radix-ui/react-one-time-password-field": "0.1.12", "@radix-ui/react-password-toggle-field": "0.1.7", "@radix-ui/react-popover": "1.1.19", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-progress": "1.1.12", "@radix-ui/react-radio-group": "1.4.3", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-scroll-area": "1.2.14", "@radix-ui/react-select": "2.3.3", "@radix-ui/react-separator": "1.1.11", "@radix-ui/react-slider": "1.4.3", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.3", "@radix-ui/react-tabs": "1.1.17", "@radix-ui/react-toast": "1.2.19", "@radix-ui/react-toggle": "1.1.14", "@radix-ui/react-toggle-group": "1.1.15", "@radix-ui/react-toolbar": "1.1.15", "@radix-ui/react-tooltip": "1.2.12", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + + "react-helmet": ["react-helmet@6.1.0", "", { "dependencies": { "object-assign": "^4.1.1", "prop-types": "^15.7.2", "react-fast-compare": "^3.1.1", "react-side-effect": "^2.1.0" }, "peerDependencies": { "react": ">=16.3.0" } }, "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="], + + "react-router-dom": ["react-router-dom@7.15.0", "", { "dependencies": { "react-router": "7.15.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ=="], + + "react-side-effect": ["react-side-effect@2.1.2", "", { "peerDependencies": { "react": "^16.3.0 || ^17.0.0 || ^18.0.0" } }, "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "universal-cookie": ["universal-cookie@7.2.2", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.7.2" } }, "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@react-router/dev/isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vite/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vite-node/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "vite-node/vite/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + } +} diff --git a/python/reflex-xy/examples/demo_app/reflex.lock/package.json b/python/reflex-xy/examples/demo_app/reflex.lock/package.json new file mode 100644 index 00000000..38739263 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/reflex.lock/package.json @@ -0,0 +1,35 @@ +{ + "name": "reflex", + "type": "module", + "scripts": { + "dev": "react-router dev --host", + "export": "react-router build" + }, + "dependencies": { + "@radix-ui/themes": "3.3.0", + "@react-router/node": "7.15.0", + "isbot": "5.1.40", + "lucide-react": "1.14.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-error-boundary": "6.1.1", + "react-helmet": "6.1.0", + "react-router": "7.15.0", + "react-router-dom": "7.15.0", + "socket.io-client": "4.8.3", + "sonner": "2.0.7", + "universal-cookie": "7.2.2" + }, + "devDependencies": { + "@emotion/react": "11.14.0", + "@react-router/dev": "7.15.0", + "@react-router/fs-routes": "7.15.0", + "autoprefixer": "10.5.0", + "postcss": "8.5.14", + "postcss-import": "16.1.1", + "vite": "8.0.16" + }, + "overrides": { + "cookie": "1.1.1" + } +} \ No newline at end of file diff --git a/python/reflex-xy/examples/demo_app/requirements.txt b/python/reflex-xy/examples/demo_app/requirements.txt new file mode 100644 index 00000000..b56af884 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/requirements.txt @@ -0,0 +1,2 @@ + +reflex==0.9.6.post2 \ No newline at end of file diff --git a/python/reflex-xy/examples/demo_app/rxconfig.py b/python/reflex-xy/examples/demo_app/rxconfig.py new file mode 100644 index 00000000..2927a202 --- /dev/null +++ b/python/reflex-xy/examples/demo_app/rxconfig.py @@ -0,0 +1,7 @@ +import reflex as rx +import reflex_xy + +config = rx.Config( + app_name="demo_app", + plugins=[reflex_xy.XYPlugin()], +) diff --git a/python/reflex-xy/pyproject.toml b/python/reflex-xy/pyproject.toml new file mode 100644 index 00000000..08cce911 --- /dev/null +++ b/python/reflex-xy/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "reflex-xy" +version = "0.1.0" +description = "Reflex integration for xy: WebGL charts over the app's own websocket" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [ + "xy", + # Prototype dependency surface. The adapter needs: Component/Var/EventHandler + # (reflex-base), plus App/State/state_manager access, which today live in the + # full `reflex` distribution. Revisit when the reflex-base split grows a + # public state/app surface (docs/design/reflex-integration.md §4). + "reflex>=0.9.6", +] + +[project.optional-dependencies] +# tests/reflex_adapter drives the data plane over a real websocket: +# uvicorn serves the socket app, aiohttp backs socketio.AsyncClient. +dev = [ + "aiohttp>=3.9", + "uvicorn>=0.23", +] + +[project.urls] +Repository = "https://github.com/reflex-dev/xy" + +[tool.hatch.build.targets.wheel] +packages = ["reflex_xy"] + +[tool.hatch.build.targets.wheel.force-include] +# The render client is a build artifact synced from the xy repo +# (node js/build.mjs); ship whatever is committed here. +"reflex_xy/assets/xy_client.js" = "reflex_xy/assets/xy_client.js" +"reflex_xy/assets/XYChart.jsx" = "reflex_xy/assets/XYChart.jsx" diff --git a/python/reflex-xy/reflex_xy/__init__.py b/python/reflex-xy/reflex_xy/__init__.py new file mode 100644 index 00000000..d73153a3 --- /dev/null +++ b/python/reflex-xy/reflex_xy/__init__.py @@ -0,0 +1,80 @@ +"""reflex-xy: xy figures as first-class Reflex components. + +The integration in one paragraph (full design: +docs/design/reflex-integration.md in the xy repo): chart data rides +the app's *existing* websocket as a second socket.io namespace — binary +columns, no JSON numbers, no extra endpoints to proxy. Figures live in a +per-process registry keyed by tokens; the tokens live in Reflex state. A +`@reflex_xy.figure` state method is both the chart definition and the +recovery recipe: any worker can rebuild the figure from state when a +reconnect lands somewhere new, so there is no central figure store to +operate. + +Quickstart:: + + # rxconfig.py + config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()]) + + # dash/dash.py + import numpy as np + import reflex as rx + import xy as fc + import reflex_xy + + class Dash(rx.State): + points: int = 200_000 + + @reflex_xy.figure + def chart(self) -> fc.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 fc.scatter_chart(fc.scatter(xs, ys), width="100%", height=460) + + def index() -> rx.Component: + return reflex_xy.chart(Dash.chart, height="460px") + + app = rx.App() +""" + +from __future__ import annotations + +from typing import Any + +from .app import XYPlugin, append, setup +from .component import chart +from .namespace import XY_NAMESPACE, XYNamespace +from .registry import FigureRegistry, _figure_of, registry +from .vars import FigureVar, figure + +__all__ = [ + "XY_NAMESPACE", + "FigureRegistry", + "FigureVar", + "XYNamespace", + "XYPlugin", + "append", + "chart", + "figure", + "register", + "registry", + "release", + "setup", +] + +__version__ = "0.1.0" + + +def register(chart_or_figure: Any) -> str: + """Imperatively register a chart; returns an opaque token for state. + + Dev-tier API: the figure lives only in this process and cannot be + rebuilt after a worker restart or on another node — prefer + `@reflex_xy.figure` for anything long-lived (see the module doc). + """ + return registry.register(_figure_of(chart_or_figure)) + + +def release(token: str) -> None: + """Drop a registered figure (idempotent).""" + registry.release(token) diff --git a/python/reflex-xy/reflex_xy/app.py b/python/reflex-xy/reflex_xy/app.py new file mode 100644 index 00000000..fa0e25e2 --- /dev/null +++ b/python/reflex-xy/reflex_xy/app.py @@ -0,0 +1,104 @@ +"""Wiring the data plane into a Reflex app. + +Two equivalent entry points, both one line for the user: + +- ``rxconfig.py``: ``plugins=[reflex_xy.XYPlugin()]`` — the plugin's + `post_compile` hook runs once at backend worker startup with the live + App and calls `setup(app)`. Zero app-code changes. +- ``app.py``: ``reflex_xy.setup(app)`` right after ``app = rx.App()`` — + the socket server already exists at that point. + +`setup` is idempotent; using both costs nothing. + +What setup does: registers the `/_xy` socket.io namespace on the app's +existing AsyncServer (same physical websocket as the app plane — see +namespace.py), wires publish fan-out, and adds a lifespan task that +captures the event loop (for thread-safe broadcasts from sync handlers) +and runs the registry TTL sweep. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import Any, Optional + +from reflex.plugins import Plugin + +from .namespace import XYNamespace +from .registry import registry +from .state_bridge import make_rebuild_hook + +__all__ = ["XYPlugin", "append", "setup"] + +_namespace: Optional[XYNamespace] = None + + +def setup(app: Any) -> XYNamespace: + """Attach the xy data plane to a Reflex app (idempotent).""" + global _namespace + if _namespace is not None: + return _namespace + sio = getattr(app, "sio", None) + if sio is None: + msg = ( + "reflex_xy.setup(app) needs the app's socket server; it exists " + "only when state is enabled (rx.App(enable_state=True), the default)." + ) + raise RuntimeError(msg) + namespace = XYNamespace(registry, rebuild=make_rebuild_hook(app)) + sio.register_namespace(namespace) + wire(namespace) + app.register_lifespan_task(_xy_lifespan) + _namespace = namespace + return namespace + + +def wire(namespace: XYNamespace) -> None: + """Point the registry's fan-out seams at a namespace (setup and tests).""" + registry.on_publish(namespace.broadcast_payload) + registry.on_push(namespace.broadcast_message) + + +async def _xy_lifespan() -> None: + """Capture the serving loop, then sweep idle figures forever.""" + registry.attach_loop(asyncio.get_running_loop()) + with contextlib.suppress(asyncio.CancelledError): # normal shutdown + await registry.sweep_forever() + + +class XYPlugin(Plugin): + """Reflex plugin: `plugins=[reflex_xy.XYPlugin()]` in rxconfig.py. + + `post_compile` is the one plugin hook that receives the live App, and it + fires at backend worker startup — after the socket server exists, before + any client connects, and never during frontend-only compiles. + """ + + def post_compile(self, **context: Any) -> None: + app = context.get("app") + if app is not None: + setup(app) + + +def append( + token: str, + x: Any, + y: Any, + *, + color: Any = None, + size: Any = None, + trace: int = 0, +) -> None: + """Stream-append points to a registered figure and push to subscribers. + + Thin alias for `registry.append` — see its docstring for the threading + contract. + """ + registry.append(token, x, y, color=color, size=size, trace=trace) + + +def reset_setup_for_tests() -> None: + """Forget the wired namespace (test isolation only).""" + global _namespace + _namespace = None diff --git a/python/reflex-xy/reflex_xy/assets/XYChart.jsx b/python/reflex-xy/reflex_xy/assets/XYChart.jsx new file mode 100644 index 00000000..01b85801 --- /dev/null +++ b/python/reflex-xy/reflex_xy/assets/XYChart.jsx @@ -0,0 +1,228 @@ +// XYChart: mount a xy figure inside a Reflex app. +// +// Transport (docs/design/reflex-integration.md): this component does NOT open +// its own connection. socket.io multiplexing reuses the app's engine.io +// websocket when the manager options match, so `xySocket()` below constructs +// its `/_xy` namespace socket with exactly the options Reflex's own +// `connect()` uses (`$/utils/state`). Whichever side runs first creates the +// shared manager; the other rides it. One TCP connection carries app state +// and chart data — same lifecycle, same auth surface, same proxy config. +// +// Data protocol (namespace.py): +// out: sub {fig, px, mid} | unsub {fig, mid} | msg {fig, mid, m} +// in: payload {fig, version, spec, buffers} — buffers are ArrayBuffers +// msg {fig, mid?, message, buffers} — replies carry our mid +// err {fig, error} +// +// The chart client itself is the same ESM bundle notebooks use (a byte-exact +// sibling copy, ./xy_client.js). Its `comm` seam is fed from socket events; +// binary columns arrive as ArrayBuffers and go straight to the GL path. + +import { useEffect, useRef } from "react"; +// Reflex compiles style props to emotion's `css`; rendering through +// emotion's jsx() (a guaranteed app dependency) honors it without relying +// on any jsxImportSource configuration in the app's build. +import { jsx } from "@emotion/react"; +import io from "socket.io-client"; +import env from "$/env.json"; +import reflexEnvironment from "$/reflex.json"; +import { getBackendURL, getToken } from "$/utils/state"; +import { ChartView } from "./xy_client.js"; + +// Opt-in console tracing: localStorage.setItem("xy_debug", "1") +const DEBUG = globalThis.localStorage?.getItem?.("xy_debug") === "1"; +const dbg = (...args) => + DEBUG && + console.log( + "[xy]", + ...args.map((a) => (typeof a === "object" && a !== null ? JSON.stringify(a) : a)), + ); +dbg("XYChart module loaded"); + +let sharedSocket = null; +// fig token -> number of mounted charts using it (unsub only at zero, since +// room membership is per-connection, not per-mount). +const subCounts = new Map(); + +function xySocket() { + if (sharedSocket) return sharedSocket; + const endpoint = getBackendURL(env.EVENT); + const nsUrl = new URL(endpoint.href); + // The URI pathname selects the socket.io *namespace*; the engine.io mount + // path stays the app's (`endpoint.pathname`), which is what keys the + // manager cache — same key as Reflex's socket, hence one physical ws. + nsUrl.pathname = "/_xy"; + nsUrl.search = ""; + sharedSocket = io(nsUrl.href, { + path: endpoint.pathname, + transports: [env.TRANSPORT], + protocols: [reflexEnvironment.version], + autoUnref: false, + query: { token: getToken() }, + reconnection: false, // the app plane owns manager reconnects + }); + sharedSocket.on("connect", () => dbg("xy namespace connected")); + sharedSocket.on("connect_error", (e) => dbg("xy connect_error", String(e))); + return sharedSocket; +} + +let nextMountId = 1; + +export function XYChart(props) { + const { + token, + onPointHover, + onPointClick, + onSelectEnd, + onViewChange, + ref: externalRef, // reflex attaches its own ref to id-bearing components + ...divProps + } = props; + const elRef = useRef(null); + dbg("render", { id: divProps.id, tokenType: typeof token, token: String(token).slice(0, 30) }); + // Live callback refs so socket handlers never close over stale props. + const cbRef = useRef({}); + cbRef.current = { onPointHover, onPointClick, onSelectEnd, onViewChange }; + + useEffect(() => { + const el = elRef.current; + dbg("effect run", { token: token && token.slice(0, 24), hasEl: !!el }); + if (!token || !el) return undefined; + const socket = xySocket(); + const mid = `m${nextMountId++}`; + let view = null; + let destroyed = false; + let clickSeq = 0; + let lastSelect = null; + const viewCallbacks = []; + + const subscribe = () => { + socket.emit("sub", { fig: token, px: el.clientWidth || null, mid }); + }; + + const comm = { + send: (m) => { + if (!m || destroyed) return; + if (m.type === "view_change") { + // Semantic event, resolved locally — the kernel round-trip would + // be a no-op (the namespace registers no Python-side callbacks). + cbRef.current.onViewChange?.(m); + return; + } + if (m.type === "select" || m.type === "select_clear") { + lastSelect = m.type === "select" ? m : null; + } + socket.emit("msg", { fig: token, mid, m }); + if (m.type === "click" && cbRef.current.onPointClick) { + // The kernel's click path resolves rows via pick; ask for the row + // with a tagged seq the reply routing below consumes. + clickSeq += 1; + socket.emit("msg", { + fig: token, + mid, + m: { + type: "pick", + trace: m.trace, + index: m.index, + drill_seq: m.drill_seq, + seq: `click:${clickSeq}`, + }, + }); + } + }, + onMessage: (cb) => { + viewCallbacks.push(cb); + return () => { + const i = viewCallbacks.indexOf(cb); + if (i >= 0) viewCallbacks.splice(i, 1); + }; + }, + }; + + const toSpans = (spec, buffers) => { + const spans = (buffers || []).map((b) => new Uint8Array(b)); + return spec.buffer_layout === "split" ? spans : spans[0]; + }; + + const onPayload = (data) => { + if (destroyed || !data || data.fig !== token) return; + if (view) view.destroy(); + viewCallbacks.length = 0; + el.replaceChildren(); + view = new ChartView(el, data.spec, toSpans(data.spec, data.buffers), comm); + // Debug/e2e handle (same spirit as the standalone example's + // window.xyLiveDrilldown): headless probes assert on live views. + (window.__xy_views ||= new Map()).set(el.id || mid, view); + }; + + const onMsg = (data) => { + if (destroyed || !data || data.fig !== token) return; + // Replies are mount-addressed; pushes (append) carry no mid. + if (data.mid !== undefined && data.mid !== null && data.mid !== mid) return; + const message = data.message; + if (!message) return; + if (typeof message.seq === "string" && message.seq.startsWith("click:")) { + if (message.type === "pick_result" && message.row) { + cbRef.current.onPointClick?.(message.row); + } + return; // synthetic pick — not for the view + } + if (message.type === "pick_result" && message.row) { + cbRef.current.onPointHover?.(message.row); + } + if (message.type === "selection" && cbRef.current.onSelectEnd) { + cbRef.current.onSelectEnd({ + total: message.total ?? 0, + x0: lastSelect?.x0 ?? null, + x1: lastSelect?.x1 ?? null, + y0: lastSelect?.y0 ?? null, + y1: lastSelect?.y1 ?? null, + cleared: message.total === 0 && lastSelect === null, + }); + } + for (const cb of [...viewCallbacks]) cb(message, data.buffers || []); + }; + + const onErr = (data) => { + if (destroyed || !data || data.fig !== token) return; + console.warn(`xy: ${data.error} (fig ${data.fig})`); + }; + + socket.on("payload", onPayload); + socket.on("msg", onMsg); + socket.on("err", onErr); + // Resubscribe on every (re)connect: after the app plane reconnects the + // shared manager, rooms are gone and — on another backend node — the + // figure itself may need a state-driven rebuild. `sub` triggers both. + socket.on("connect", subscribe); + subCounts.set(token, (subCounts.get(token) || 0) + 1); + if (socket.connected) subscribe(); + + return () => { + destroyed = true; + socket.off("payload", onPayload); + socket.off("msg", onMsg); + socket.off("err", onErr); + socket.off("connect", subscribe); + const remaining = (subCounts.get(token) || 1) - 1; + if (remaining <= 0) { + subCounts.delete(token); + if (socket.connected) socket.emit("unsub", { fig: token, mid }); + } else { + subCounts.set(token, remaining); + } + if (view) view.destroy(); + view = null; + window.__xy_views?.delete(el.id || mid); + el.replaceChildren(); + }; + }, [token]); + + // One DOM node, two consumers: our mount logic and reflex's ref registry. + const mergedRef = (node) => { + elRef.current = node; + if (typeof externalRef === "function") externalRef(node); + else if (externalRef) externalRef.current = node; + }; + return jsx("div", { ...divProps, ref: mergedRef }); +} diff --git a/python/reflex-xy/reflex_xy/assets/__init__.py b/python/reflex-xy/reflex_xy/assets/__init__.py new file mode 100644 index 00000000..43ba6cdb --- /dev/null +++ b/python/reflex-xy/reflex_xy/assets/__init__.py @@ -0,0 +1,29 @@ +"""Frontend assets for the Reflex component. + +Two files ship here: + +- ``XYChart.jsx`` — the React wrapper (multiplexes the `/_xy` namespace onto + the app's existing websocket and drives ChartView). +- ``xy_client.js`` — a byte-exact copy of the render client + (``python/xy/static/index.js``); ``node js/build.mjs`` regenerates both + and ``tests/reflex_xy/test_assets.py`` fails on drift. + +`register()` is deliberately lazy (called from the component factory, not at +import): ``rx.asset(shared=True)`` symlinks into ``Path.cwd()/assets``, which +only makes sense while compiling an actual Reflex app. It must be called +from *this* module so the files land in one directory and the wrapper's +relative ``./xy_client.js`` import resolves. +""" + +from __future__ import annotations + +WRAPPER_TAG = "XYChart" + + +def register() -> str: + """Symlink both assets into the compiling app; return the wrapper's + importable module path (``$/public/external/reflex_xy/assets/...``).""" + import reflex as rx + + rx.asset("xy_client.js", shared=True) + return rx.asset("XYChart.jsx", shared=True).importable_path diff --git a/python/reflex-xy/reflex_xy/assets/xy_client.js b/python/reflex-xy/reflex_xy/assets/xy_client.js new file mode 100644 index 00000000..a51bfcff --- /dev/null +++ b/python/reflex-xy/reflex_xy/assets/xy_client.js @@ -0,0 +1,6066 @@ + +"use strict"; +const PROTOCOL = 3; +const XY_FRAME_MAGIC = [0x58, 0x59, 0x42, 0x46]; +const XY_FRAME_VERSION = 1; +const XY_FRAME_HEADER_SIZE = 24; +const XY_FRAME_ALIGNMENT = 8; +const XY_FRAME_DEFAULT_LIMITS = Object.freeze({ +maxFrameBytes: 512 * 1024 * 1024, +maxMetadataBytes: 8 * 1024 * 1024, +maxBuffers: 4096, +maxBufferBytes: 256 * 1024 * 1024, +}); +function fcByteSpan(value, label = "buffer") { +if (value instanceof ArrayBuffer) return new Uint8Array(value); +if (ArrayBuffer.isView(value)) { +return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} +throw new TypeError(`${label} must be an ArrayBuffer or ArrayBuffer view`); +} +function fcFrameLimit(limits, name) { +const fallback = XY_FRAME_DEFAULT_LIMITS[name]; +const value = limits && limits[name] != null ? limits[name] : fallback; +if (!Number.isSafeInteger(value) || value <= 0) { +throw new RangeError(`${name} must be a positive safe integer`); +} +return value; +} +function fcAlign8(value) { +return Math.ceil(value / XY_FRAME_ALIGNMENT) * XY_FRAME_ALIGNMENT; +} +function fcFrameU64(view, offset, label) { +const value = view.getBigUint64(offset, true); +if (value > BigInt(Number.MAX_SAFE_INTEGER)) { +throw new RangeError(`${label} exceeds JavaScript's safe integer range`); +} +return Number(value); +} +function fcRequireZeroPadding(bytes, start, end, label) { +if (end > bytes.byteLength) throw new RangeError(`truncated ${label} padding`); +for (let i = start; i < end; i++) { +if (bytes[i] !== 0) throw new RangeError(`non-zero ${label} padding`); +} +} + +function decodeFrame(body, limits = null) { +const bytes = fcByteSpan(body, "frame body"); +const maxFrameBytes = fcFrameLimit(limits, "maxFrameBytes"); +const maxMetadataBytes = fcFrameLimit(limits, "maxMetadataBytes"); +const maxBuffers = fcFrameLimit(limits, "maxBuffers"); +const maxBufferBytes = fcFrameLimit(limits, "maxBufferBytes"); +if (maxMetadataBytes > maxFrameBytes) { +throw new RangeError("maxMetadataBytes cannot exceed maxFrameBytes"); +} +if (maxBufferBytes > maxFrameBytes) { +throw new RangeError("maxBufferBytes cannot exceed maxFrameBytes"); +} +if (bytes.byteOffset % XY_FRAME_ALIGNMENT !== 0) { +throw new RangeError("frame body must start on an 8-byte boundary"); +} +if (bytes.byteLength > maxFrameBytes) { +throw new RangeError(`frame length ${bytes.byteLength} exceeds limit ${maxFrameBytes}`); +} +if (bytes.byteLength < XY_FRAME_HEADER_SIZE) throw new RangeError("truncated frame header"); +const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +for (let i = 0; i < XY_FRAME_MAGIC.length; i++) { +if (view.getUint8(i) !== XY_FRAME_MAGIC[i]) throw new RangeError("invalid frame magic"); +} +const version = view.getUint8(4); +if (version !== XY_FRAME_VERSION) throw new RangeError(`unsupported frame version ${version}`); +const flags = view.getUint8(5); +if (flags !== 0) throw new RangeError(`unsupported frame flags 0x${flags.toString(16)}`); +const headerSize = view.getUint16(6, true); +if (headerSize !== XY_FRAME_HEADER_SIZE) { +throw new RangeError(`unsupported frame header size ${headerSize}`); +} +const metadataLength = view.getUint32(8, true); +const bufferCount = view.getUint32(12, true); +const totalLength = fcFrameU64(view, 16, "declared frame length"); +if (totalLength !== bytes.byteLength) { +throw new RangeError( +`declared frame length ${totalLength} does not match body length ${bytes.byteLength}` +); +} +if (metadataLength > maxMetadataBytes) { +throw new RangeError(`metadata length ${metadataLength} exceeds limit ${maxMetadataBytes}`); +} +if (bufferCount > maxBuffers) { +throw new RangeError(`buffer count ${bufferCount} exceeds limit ${maxBuffers}`); +} +const metadataEnd = XY_FRAME_HEADER_SIZE + metadataLength; +if (metadataEnd > bytes.byteLength) throw new RangeError("truncated frame metadata"); +let message; +try { +const metadataBytes = new Uint8Array( +bytes.buffer, +bytes.byteOffset + XY_FRAME_HEADER_SIZE, +metadataLength +); +message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(metadataBytes)); +} catch (error) { +throw new RangeError(`invalid frame metadata JSON: ${error}`); +} +if (!message || Array.isArray(message) || typeof message !== "object") { +throw new RangeError("frame metadata must decode to an object"); +} +let position = fcAlign8(metadataEnd); +fcRequireZeroPadding(bytes, metadataEnd, position, "metadata"); +const buffers = []; +for (let i = 0; i < bufferCount; i++) { +if (position + 8 > bytes.byteLength) throw new RangeError(`truncated buffer ${i} length`); +const bufferLength = fcFrameU64(view, position, `buffer ${i} length`); +position += 8; +if (bufferLength > maxBufferBytes) { +throw new RangeError(`buffer ${i} length ${bufferLength} exceeds limit ${maxBufferBytes}`); +} +const end = position + bufferLength; +if (end > bytes.byteLength) throw new RangeError(`truncated buffer ${i}`); +const absoluteOffset = bytes.byteOffset + position; +if (absoluteOffset % XY_FRAME_ALIGNMENT !== 0) { +throw new RangeError(`buffer ${i} is not 8-byte aligned`); +} +buffers.push(new Uint8Array(bytes.buffer, absoluteOffset, bufferLength)); +const paddedEnd = fcAlign8(end); +fcRequireZeroPadding(bytes, end, paddedEnd, `buffer ${i}`); +position = paddedEnd; +} +if (position !== bytes.byteLength) { +throw new RangeError(`frame has ${bytes.byteLength - position} trailing bytes`); +} +return { message, buffers, version: XY_FRAME_VERSION, byteLength: bytes.byteLength }; +} +const COLORMAP_STOPS = { +binary: [[255, 255, 255], [0, 0, 0]], +gray: [[0, 0, 0], [25, 25, 25], [51, 51, 51], [76, 76, 76], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [230, 230, 230], [255, 255, 255]], +viridis: [[68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], [122, 209, 81], [189, 223, 38], [253, 231, 37]], +plasma: [[13, 8, 135], [65, 4, 157], [106, 0, 168], [143, 13, 164], [177, 42, 144], [204, 71, 120], [225, 100, 98], [242, 132, 75], [252, 166, 54], [252, 206, 37], [240, 249, 33]], +inferno: [[0, 0, 4], [22, 11, 57], [66, 10, 104], [106, 23, 110], [147, 38, 103], [188, 55, 84], [221, 81, 58], [243, 120, 25], [252, 165, 10], [246, 215, 70], [252, 255, 164]], +magma: [[0, 0, 4], [20, 14, 54], [59, 15, 112], [100, 26, 128], [140, 41, 129], [183, 55, 121], [222, 73, 104], [247, 112, 92], [254, 159, 109], [254, 207, 146], [252, 253, 191]], +cividis: [[0, 34, 78], [8, 51, 112], [53, 69, 108], [79, 87, 108], [102, 105, 112], [125, 124, 120], [148, 142, 119], [174, 163, 113], [200, 184, 102], [229, 207, 82], [254, 232, 56]], +coolwarm: [[59, 76, 192], [89, 119, 227], [123, 159, 249], [158, 190, 255], [192, 212, 245], [221, 220, 220], [242, 203, 183], [247, 172, 142], [238, 132, 104], [214, 82, 68], [180, 4, 38]], +turbo: [[48, 18, 59], [69, 89, 203], [62, 155, 254], [25, 213, 205], [70, 248, 132], [164, 252, 60], [225, 221, 55], [254, 164, 49], [240, 91, 18], [195, 37, 3], [122, 4, 3]], +rainbow: [[128, 0, 255], [78, 77, 252], [25, 150, 243], [24, 205, 228], [77, 243, 206], [128, 255, 180], [178, 243, 150], [230, 205, 115], [255, 150, 79], [255, 77, 39], [255, 0, 0]], +jet: [[0, 0, 128], [0, 0, 241], [0, 76, 255], [0, 176, 255], [41, 255, 206], [125, 255, 122], [206, 255, 41], [255, 196, 0], [255, 104, 0], [241, 8, 0], [128, 0, 0]], +rdgy: [[103, 0, 31], [177, 24, 43], [214, 96, 77], [243, 164, 129], [253, 219, 199], [254, 254, 254], [224, 224, 224], [185, 185, 185], [135, 135, 135], [76, 76, 76], [26, 26, 26]], +rdbu: [[103, 0, 31], [177, 24, 43], [214, 96, 77], [243, 164, 129], [253, 219, 199], [246, 247, 247], [209, 229, 240], [144, 196, 221], [67, 147, 195], [32, 101, 171], [5, 48, 97]], +blues: [[247, 251, 255], [227, 238, 249], [208, 225, 242], [183, 212, 234], [148, 196, 223], [106, 174, 214], [74, 152, 201], [46, 126, 188], [23, 100, 171], [8, 74, 145], [8, 48, 107]], +purples: [[252, 251, 253], [242, 240, 247], [226, 226, 239], [206, 207, 229], [182, 182, 216], [158, 154, 200], [134, 131, 189], [114, 98, 172], [97, 64, 155], [79, 31, 139], [63, 0, 125]], +pubu: [[255, 247, 251], [240, 234, 244], [219, 218, 235], [192, 201, 226], [156, 185, 217], [115, 169, 207], [66, 149, 195], [24, 124, 182], [5, 103, 162], [4, 83, 130], [2, 56, 88]], +piyg: [[142, 1, 82], [196, 26, 124], [222, 119, 174], [241, 181, 217], [253, 224, 239], [247, 247, 246], [230, 245, 208], [183, 224, 133], [127, 188, 65], [76, 145, 33], [39, 100, 25]], +prgn: [[64, 0, 75], [117, 41, 130], [153, 112, 171], [193, 164, 206], [231, 212, 232], [246, 247, 246], [217, 240, 211], [165, 218, 159], [90, 174, 97], [26, 119, 54], [0, 68, 27]], +rdylgn: [[165, 0, 38], [214, 47, 39], [244, 109, 67], [253, 173, 96], [254, 224, 139], [254, 255, 190], [217, 239, 139], [165, 216, 106], [102, 189, 99], [25, 151, 80], [0, 104, 55]], +spectral: [[158, 1, 66], [212, 61, 79], [244, 109, 67], [253, 173, 96], [254, 224, 139], [255, 255, 190], [230, 245, 152], [170, 220, 164], [102, 194, 165], [51, 135, 188], [94, 79, 162]], +}; +function colormapStops(name) { +const reversed = typeof name === "string" && name.endsWith("_r"); +const base = reversed ? name.slice(0, -2) : name; +const stops = COLORMAP_STOPS[base] || COLORMAP_STOPS.viridis; +return reversed ? [...stops].reverse() : stops; +} +function buildLutData(name) { +const stops = colormapStops(name); +const N = 256; +const data = new Uint8Array(N * 4); +for (let i = 0; i < N; i++) { +const t = (i / (N - 1)) * (stops.length - 1); +const lo = Math.floor(t); +const hi = Math.min(lo + 1, stops.length - 1); +const f = t - lo; +for (let c = 0; c < 3; c++) { +data[i * 4 + c] = Math.round(stops[lo][c] * (1 - f) + stops[hi][c] * f); +} +data[i * 4 + 3] = 255; +} +return data; +} +function resolveCssColor(host, expr) { +const probe = document.createElement("span"); +probe.style.display = "none"; +probe.style.color = expr; +host.appendChild(probe); +const rgb = getComputedStyle(probe).color; +host.removeChild(probe); +const m = rgb.match(/rgba?\(([^)]+)\)/); +if (!m) return null; +const parts = m[1].split(/[,/\s]+/).filter(Boolean).map(Number); +const [r, g, b, a = 1] = parts; +return [r / 255, g / 255, b / 255, a]; +} +function cssToken(el, name) { +const v = getComputedStyle(el).getPropertyValue(name).trim(); +return v || null; +} +function hexColor(hex) { +const h = hex.replace("#", ""); +if (!/^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(h)) { +return null; +} +const full = h.length === 3 || h.length === 4 ? [...h].map((c) => c + c).join("") : h; +const n = parseInt(full.slice(0, 6), 16); +const a = full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1; +return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a]; +} +function parseColor(host, c, fallback) { +if (!c) return fallback; +if (typeof c !== "string") return fallback; +const expr = c.trim(); +if (!expr) return fallback; +const out = expr.startsWith("#") ? hexColor(expr) : resolveCssColor(host, expr); +if (out) return out; +if (typeof console !== "undefined" && console.warn) { +console.warn(`xy: unresolvable color ${JSON.stringify(expr)}; using fallback`); +} +return fallback; +} +function readTheme(root) { +const text = resolveCssColor(root, "currentColor") || [0.2, 0.2, 0.2, 1]; +const withA = (c, a) => [c[0], c[1], c[2], a]; +const tok = (name) => { +const v = cssToken(root, name); +return v ? resolveCssColor(root, v) || null : null; +}; +return { +bg: tok("--chart-bg"), +grid: tok("--chart-grid") || withA(text, 0.14), +axis: tok("--chart-axis") || withA(text, 0.55), +label: tok("--chart-text") || withA(text, 0.85), +}; +} +function cssColor([r, g, b, a]) { +return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`; +} +const FC_CHROME_CSS = ` +:where(.xy [data-fc-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)} +:where(.xy [data-fc-slot="tooltip"]){background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)} +:where(.xy [data-fc-slot="legend"]){gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)} +:where(.xy [data-fc-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px} +:where(.xy [data-fc-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px} +:where(.xy [data-fc-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box} +:where(.xy [data-fc-slot="colorbar_title"]){font-weight:500} +:where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} +:where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)} +:where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)} +:where(.xy [data-fc-slot="modebar_button"]){width:26px;height:24px;padding:0;border:none;background:transparent;border-radius:3px;color:var(--chart-axis,currentColor);cursor:pointer} +:where(.xy [data-fc-slot="modebar_button"].fc-active){background:var(--chart-modebar-active,rgba(128,128,128,.22))} +:where(.xy [data-fc-slot="selection"]){border:1px solid var(--chart-selection,rgba(90,140,240,.9));background:var(--chart-selection-fill,rgba(90,140,240,.15))} +:where(.xy [data-fc-slot="selection"][data-fc-band="zoom"]){border-color:var(--chart-zoom-selection,rgba(120,120,120,.9));background:var(--chart-zoom-selection-fill,rgba(120,120,120,.12))} +:where(.xy [data-fc-slot="crosshair_x"],.xy [data-fc-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} +:where(.xy [data-fc-slot="tick_label"]){color:var(--chart-text,inherit)} +:where(.xy [data-fc-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px} +:where(.xy [data-fc-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))} +:where(.xy [data-fc-slot="canvas"]){cursor:var(--chart-cursor,crosshair)} +:where(.xy [data-fc-slot="canvas"][data-fc-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)} +`; +function ensureChromeStylesheet(node) { +let root = node && node.getRootNode ? node.getRootNode() : document; +const isShadow = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot; +if (!isShadow && !(root instanceof Document)) root = document; +const scope = isShadow ? root : (root.head || document.head || root.documentElement); +if (!scope || !scope.querySelector) return; +if (scope.querySelector("style[data-xy-chrome]")) return; +const style = document.createElement("style"); +style.setAttribute("data-xy-chrome", ""); +style.textContent = FC_CHROME_CSS; +scope.appendChild(style); +} +function safeCssPaint(host, expr, fallback = [0.5, 0.5, 0.5, 1]) { +const parsed = parseColor(host, expr, fallback); +const color = Array.isArray(parsed) && parsed.length >= 4 && parsed.every(Number.isFinite) +? parsed +: fallback; +return cssColor(color); +} +function niceStep(rough) { +rough = Math.abs(rough); +if (!Number.isFinite(rough) || rough <= 0) return 1; +const mag = Math.pow(10, Math.floor(Math.log10(rough))); +for (const m of [1, 2, 2.5, 5, 10]) { +if (rough <= m * mag * (1 + 1e-12)) return m * mag; +} +return 10 * mag; +} +function linearTicks(lo, hi, target = 6) { +if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 }; +const a = Math.min(lo, hi); +const b = Math.max(lo, hi); +if (a === b) return { ticks: [a], step: 1 }; +const step = niceStep((b - a) / target); +const first = Math.ceil(a / step) * step; +const out = []; +for (let v = first; v <= b + step * 1e-9 && out.length < 200; v += step) { +out.push(Math.abs(v) < step * 1e-9 ? 0 : v); +} +return { ticks: out, step }; +} +function logTicks(lo, hi, target = 6) { +if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 }; +const a = Math.min(lo, hi); +const b = Math.max(lo, hi); +if (a <= 0 || b <= 0) return { ticks: [], step: 1 }; +const e0 = Math.floor(Math.log10(a)); +const e1 = Math.ceil(Math.log10(b)); +const span = Math.max(1, e1 - e0); +const mults = span <= Math.max(2, target) ? [1, 2, 5] : [1]; +const out = []; +const labels = []; +const labelEvery = Math.max(1, Math.ceil((e1 - e0 + 1) / Math.max(1, target))); +for (let e = e0; e <= e1 && out.length < 200; e++) { +const base = Math.pow(10, e); +for (const m of mults) { +const v = m * base; +if (v >= a * (1 - 1e-12) && v <= b * (1 + 1e-12)) { +out.push(v); +if (m === 1 && (e - e0) % labelEvery === 0) labels.push(v); +} +if (out.length >= 200) break; +} +} +return { ticks: out, labels: labels.length ? labels : out, step: 1, log: true }; +} +function categoryTicks(lo, hi, categories, target = 6) { +if (!categories || !categories.length) return { ticks: [], step: 1 }; +const start = Math.max(0, Math.ceil(Math.min(lo, hi))); +const stop = Math.min(categories.length - 1, Math.floor(Math.max(lo, hi))); +if (stop < start) return { ticks: [], step: 1 }; +const visible = stop - start + 1; +const step = Math.max(1, Math.ceil(visible / Math.max(1, target))); +const out = []; +for (let v = start; v <= stop && out.length < 200; v += step) out.push(v); +return { ticks: out, step }; +} +const MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }; +const TIME_STEPS = [ +1, 2, 5, 10, 20, 50, 100, 200, 500, +MS.s, 2 * MS.s, 5 * MS.s, 10 * MS.s, 15 * MS.s, 30 * MS.s, +MS.m, 2 * MS.m, 5 * MS.m, 10 * MS.m, 15 * MS.m, 30 * MS.m, +MS.h, 2 * MS.h, 3 * MS.h, 6 * MS.h, 12 * MS.h, +MS.d, 2 * MS.d, 7 * MS.d, 14 * MS.d, +]; +function timeTicks(lo, hi, target = 6) { +if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: MS.d }; +const a = Math.min(lo, hi); +const b = Math.max(lo, hi); +const span = b - a; +const rough = span / target; +if (rough > 14 * MS.d) return calendarTicks(a, b, rough); +let step = TIME_STEPS[TIME_STEPS.length - 1]; +for (const s of TIME_STEPS) { +if (s >= rough) { step = s; break; } +} +const first = Math.ceil(a / step) * step; +const out = []; +for (let v = first; v <= b && out.length < 200; v += step) out.push(v); +return { ticks: out, step }; +} +function calendarTicks(lo, hi, rough) { +const monthsRough = rough / (30 * MS.d); +const monthSteps = [1, 2, 3, 6, 12, 24, 60, 120]; +let stepM = monthSteps[monthSteps.length - 1]; +for (const s of monthSteps) { +if (s >= monthsRough) { stepM = s; break; } +} +const d = new Date(lo); +let y = d.getUTCFullYear(); +let m = d.getUTCMonth(); +m = Math.ceil(m / stepM) * stepM; +const out = []; +for (;;) { +const t = Date.UTC(y + Math.floor(m / 12), m % 12, 1); +if (t > hi) break; +if (t >= lo) out.push(t); +m += stepM; +if (out.length > 1000) break; +} +return { ticks: out, step: stepM * 30 * MS.d }; +} +function fmtTime(ms, step) { +const d = new Date(ms); +const pad = (n, w = 2) => String(n).padStart(w, "0"); +if (step >= 28 * MS.d) { +const mo = d.getUTCMonth(); +return mo === 0 ? String(d.getUTCFullYear()) +: `${d.toLocaleString("en", { month: "short", timeZone: "UTC" })} ${d.getUTCFullYear()}`; +} +if (step >= MS.d) return `${d.toLocaleString("en", { month: "short", timeZone: "UTC" })} ${pad(d.getUTCDate())}`; +if (step >= MS.m) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +if (step >= MS.s) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; +return `${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(), 3)}`; +} +function fmtLinear(v, step) { +const av = Math.abs(v); +if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e"); +let dec = step ? Math.max(0, Math.ceil(-Math.log10(Math.abs(step)))) : 0; +while (dec < 8 && Math.abs(Number(step.toFixed(dec)) - step) > Math.abs(step) / 1000) dec++; +return v.toFixed(Math.min(dec, 8)); +} +function fmtCategory(v, categories) { +const i = Math.round(v); +return i >= 0 && i < categories.length ? String(categories[i]) : ""; +} +function fmtNumberSpec(v, format) { +if (typeof format !== "string" || !Number.isFinite(Number(v))) return null; +const percent = format.endsWith("%"); +const raw = percent ? format.slice(0, -1) : format; +const match = raw.match(/^(,)?\.([0-9]+)f?$/); +if (!match) return null; +const digits = Number(match[2]); +const value = percent ? Number(v) * 100 : Number(v); +const text = match[1] +? value.toLocaleString(undefined, { +minimumFractionDigits: digits, +maximumFractionDigits: digits, +}) +: value.toFixed(digits); +return percent ? `${text}%` : text; +} +function fmtTimeSpec(ms, format) { +if (typeof format !== "string") return null; +const d = new Date(ms); +if (!Number.isFinite(d.getTime())) return null; +const pad = (n, w = 2) => String(n).padStart(w, "0"); +const shortMonth = d.toLocaleString("en", { month: "short", timeZone: "UTC" }); +const longMonth = d.toLocaleString("en", { month: "long", timeZone: "UTC" }); +return format.replace(/%[YmdHMSbB]/g, (token) => { +switch (token) { +case "%Y": return String(d.getUTCFullYear()); +case "%m": return pad(d.getUTCMonth() + 1); +case "%d": return pad(d.getUTCDate()); +case "%H": return pad(d.getUTCHours()); +case "%M": return pad(d.getUTCMinutes()); +case "%S": return pad(d.getUTCSeconds()); +case "%b": return shortMonth; +case "%B": return longMonth; +default: return token; +} +}); +} +function fmtAxis(axis, v, tickStep) { +if (axis && axis.kind === "category") return fmtCategory(v, axis.categories || []); +if (axis && axis.kind === "time") return fmtTimeSpec(v, axis.format) || fmtTime(v, tickStep); +const formatted = fmtNumberSpec(v, axis && axis.format); +if (axis && axis.scale === "log" && Number(v) > 0 && Number(v) < 1 && formatted === "0") { +return fmtLinear(v, tickStep); +} +return formatted || fmtLinear(v, tickStep); +} +function fmtValue(v, kind) { +if (kind === "time_ms") { +const d = new Date(v); +return d.toISOString().replace("T", " ").replace(".000Z", "Z"); +} +if (typeof v === "string") return v; +const n = Number(v); +if (!Number.isFinite(n)) return String(v); +if (n === 0) return "0"; +const av = Math.abs(n); +if (av >= 1e6 || av < 1e-4) return n.toExponential(3); +return (Math.round(n * 1e4) / 1e4).toString(); +} +function compile(gl, type, src) { +const sh = gl.createShader(type); +gl.shaderSource(sh, src); +gl.compileShader(sh); +if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { +throw new Error("shader compile: " + gl.getShaderInfoLog(sh) + "\n" + src); +} +return sh; +} +const ATTR_SLOTS = { +ax: 0, ay: 1, +ax0: 0, ax1: 1, ay0: 2, ay1: 3, ax2: 4, ay2: 5, ab0: 4, ab1: 5, +a_pos: 0, a_v1: 1, a_v0: 2, +a_corner: 0, +a_cval: 6, a_sval: 7, a_sel: 8, a_dval: 9, +a_len0: 10, a_len1: 11, +a_dash0: 10, a_dashDir: 11, +}; +function makeProgram(gl, vs, fs) { +const p = gl.createProgram(); +const vsh = compile(gl, gl.VERTEX_SHADER, vs); +const fsh = compile(gl, gl.FRAGMENT_SHADER, fs); +gl.attachShader(p, vsh); +gl.attachShader(p, fsh); +for (const [name, slot] of Object.entries(ATTR_SLOTS)) { +gl.bindAttribLocation(p, slot, name); +} +gl.linkProgram(p); +const ok = gl.getProgramParameter(p, gl.LINK_STATUS); +const info = gl.getProgramInfoLog(p); +gl.detachShader(p, vsh); +gl.detachShader(p, fsh); +gl.deleteShader(vsh); +gl.deleteShader(fsh); +if (!ok) { +gl.deleteProgram(p); +throw new Error("program link: " + info); +} +p._u = Object.create(null); +return p; +} +function uniformOf(gl, prog, name) { +let loc = prog._u[name]; +if (loc === undefined) { +loc = gl.getUniformLocation(prog, name); +prog._u[name] = loc; +} +return loc; +} +const AXIS_GLSL = ` +float fcDecode(float encoded, vec2 meta) { + return encoded / max(abs(meta.y), 1e-30) + meta.x; +} +float fcAxisCoord(float encoded, vec2 meta, int mode) { + float value = fcDecode(encoded, meta); + if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; + return value; +} +float fcMap(float encoded, vec2 map, vec2 meta, int mode) { + return fcAxisCoord(encoded, meta, mode) * map.x + map.y; +} +float fcViewCoord(float value, int mode) { + if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; + return value; +} +float fcViewValue(float coord, int mode) { + if (mode == 1) return pow(10.0, coord); + return coord; +} +`; +const POINT_VS = `#version 300 es +in float ax; in float ay; in float a_cval; in float a_sval; in float a_sel; in float a_dval; +uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; +uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; +uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; +uniform float u_selectedOpacity; uniform float u_unselectedOpacity; +out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; +${AXIS_GLSL} +void main() { + gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); + float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; + gl_PointSize = sz * u_dpr; + v_ptSize = sz * u_dpr; + v_sel = a_sel; + // continuous: coord = value in [0,1]; categorical: center of texel a_cval. + v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + // Local log-density LUT coord (drill handoff, §5): lets freshly drilled + // points wear the density colormap so the texture->points swap is seamless. + v_dval = a_dval; + // Unselected marks dim when a selection is active (§34 selected/unselected styling). + v_dim = u_selActive == 1 ? mix(u_unselectedOpacity, u_selectedOpacity, step(0.5, a_sel)) : 1.0; +}`; +const MARKER_SDF_GLSL = ` +float fcSegmentDistance(vec2 p, vec2 a, vec2 b) { + vec2 e = b - a; + return length(p - a - e * clamp(dot(p - a, e) / dot(e, e), 0.0, 1.0)); +} +float fcTriangleDistance(vec2 p, vec2 a, vec2 b, vec2 c) { + float dist = min(fcSegmentDistance(p, a, b), + min(fcSegmentDistance(p, b, c), fcSegmentDistance(p, c, a))); + float c0 = (b.x-a.x)*(p.y-a.y) - (b.y-a.y)*(p.x-a.x); + float c1 = (c.x-b.x)*(p.y-b.y) - (c.y-b.y)*(p.x-b.x); + float c2 = (a.x-c.x)*(p.y-c.y) - (a.y-c.y)*(p.x-c.x); + bool inside = (c0 >= 0.0 && c1 >= 0.0 && c2 >= 0.0) || + (c0 <= 0.0 && c1 <= 0.0 && c2 <= 0.0); + return inside ? -dist : dist; +} +float fcPentagonDistance(vec2 p) { + // Path.unit_regular_polygon(5), then Matplotlib's 0.5 marker transform. + vec2 a = vec2(0.0, -0.5); + vec2 b = vec2(-0.475528258, -0.154508497); + vec2 c = vec2(-0.293892626, 0.404508497); + vec2 d = vec2(0.293892626, 0.404508497); + vec2 e = vec2(0.475528258, -0.154508497); + float dist = min(min(fcSegmentDistance(p, a, b), fcSegmentDistance(p, b, c)), + min(min(fcSegmentDistance(p, c, d), fcSegmentDistance(p, d, e)), + fcSegmentDistance(p, e, a))); + float c0 = (b.x-a.x)*(p.y-a.y) - (b.y-a.y)*(p.x-a.x); + float c1 = (c.x-b.x)*(p.y-b.y) - (c.y-b.y)*(p.x-b.x); + float c2 = (d.x-c.x)*(p.y-c.y) - (d.y-c.y)*(p.x-c.x); + float c3 = (e.x-d.x)*(p.y-d.y) - (e.y-d.y)*(p.x-d.x); + float c4 = (a.x-e.x)*(p.y-e.y) - (a.y-e.y)*(p.x-e.x); + bool inside = (c0 >= 0.0 && c1 >= 0.0 && c2 >= 0.0 && c3 >= 0.0 && c4 >= 0.0) || + (c0 <= 0.0 && c1 <= 0.0 && c2 <= 0.0 && c3 <= 0.0 && c4 <= 0.0); + return inside ? -dist : dist; +} +float fcMarkerSdf(vec2 d, int shape) { + if (shape == 1) return max(abs(d.x), abs(d.y)) - 0.5; // square + if (shape == 2) return (abs(d.x) + abs(d.y)) - 0.5; // diamond + if (shape == 4) { // cross / plus + vec2 a = abs(d); + return min(max(a.x - 0.17, a.y - 0.5), max(a.x - 0.5, a.y - 0.17)); + } + if (shape == 5) { // regular hexagon (pointy top) + const vec3 k = vec3(-0.866025404, 0.5, 0.577350269); + vec2 p = abs(vec2(d.y, d.x)); + p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; + p -= vec2(clamp(p.x, -k.z * 0.5, k.z * 0.5), 0.5); + return length(p) * sign(p.y); + } + if (shape == 6) return fcPentagonDistance(d); // exact regular pentagon + if (shape == 7) { // five-pointed star (apex up) + const float rf = 0.45; + const vec2 k1 = vec2(0.809016994, -0.587785252); + const vec2 k2 = vec2(-k1.x, k1.y); + vec2 p = vec2(abs(d.x), -d.y); + p -= 2.0 * max(dot(k1, p), 0.0) * k1; + p -= 2.0 * max(dot(k2, p), 0.0) * k2; + p = vec2(abs(p.x), p.y - 0.5); + vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0.0, 1.0); + float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, 0.5); + return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); + } + if (shape == 3 || shape == 8 || shape == 9 || shape == 10) { // Matplotlib triangle path + vec2 q = d; + if (shape == 8) q = -d; + if (shape == 9) q = vec2(d.y, -d.x); + if (shape == 10) q = vec2(-d.y, d.x); + return fcTriangleDistance(q, vec2(0.0, -0.5), vec2(-0.5, 0.5), vec2(0.5, 0.5)); + } + if (shape == 11) { // diagonal x + vec2 q = vec2(d.x + d.y, d.y - d.x) * 0.707106781; + vec2 a = abs(q); + return min(max(a.x - 0.17, a.y - 0.5), max(a.x - 0.5, a.y - 0.17)); + } + if (shape == 13) return max(abs(d.x), abs(d.y)) - 0.5; // snapped pixel + if (shape == 14) return (abs(d.x) / 0.6 + abs(d.y)) - 0.5; // thin diamond + return length(d) - 0.5; // circle +}`; +const POINT_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; +uniform sampler2D u_dlut; uniform float u_dblend; +uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; +uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; +in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in float v_sel; +out vec4 outColor; +${MARKER_SDF_GLSL} +void main() { + vec2 d = gl_PointCoord - 0.5; + float sd; + bool lineMarker = u_symbol == 15 || u_symbol == 16; + if (lineMarker) { + vec2 q = u_symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; + float halfWidth = max(u_ptStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); + vec2 a = abs(q); + sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); + } else { + sd = fcMarkerSdf(d, u_symbol); + } + float aa = fwidth(sd) + 1e-4; + float shapeCov = clamp(0.5 - sd / aa, 0.0, 1.0); + if (shapeCov <= 0.001) discard; + vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; + // Drill handoff (§5): near the density boundary, paint by local density with + // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). + if (u_dblend > 0.001) { + vec3 drgb = texture(u_dlut, vec2(clamp(v_dval, 0.0, 1.0), 0.5)).rgb; + rgb = mix(rgb, drgb, u_dblend); + } + // §34 selected/unselected recolor: when a selection is active, tint each point + // toward its state color (.a is the mix weight; 0 = keep native color). + if (u_selActive == 1) { + vec4 sc = v_sel > 0.5 ? u_selColor : u_unselColor; + rgb = mix(rgb, sc.rgb, sc.a); + } + float fillAlpha = u_opacity; + vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill + vec4 strokePx = u_ptStrokeFace == 1 ? px : u_ptStroke; + if (lineMarker) { + outColor = strokePx * (shapeCov * v_dim); + return; + } + if (u_ptStrokeWidth > 0.0) { + float sw = u_ptStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units + // The supplied point size includes the edge. Recover Matplotlib's path + // boundary half a stroke inside it, then source-over the centered stroke. + float pathCov = clamp(0.5 - (sd + sw * 0.5) / aa, 0.0, 1.0); + float innerCov = clamp(0.5 - (sd + sw) / aa, 0.0, 1.0); + float strokeCov = max(shapeCov - innerCov, 0.0); + vec4 fillLayer = px * pathCov; + vec4 strokeLayer = strokePx * strokeCov; + px = strokeLayer + fillLayer * (1.0 - strokeLayer.a); + outColor = px * v_dim; + return; + } + outColor = px * (shapeCov * v_dim); +}`; +const POINT_SIMPLE_VS = `#version 300 es +in float ax; in float ay; +uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; +uniform float u_size; uniform float u_dpr; +${AXIS_GLSL} +void main() { + gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); + gl_PointSize = u_size * u_dpr; +}`; +const POINT_SIMPLE_FS = `#version 300 es +precision highp float; +uniform vec4 u_color; +out vec4 outColor; +void main() { + float sd = length(gl_PointCoord - 0.5) - 0.5; + float aa = fwidth(sd) + 1e-4; + float coverage = clamp(0.5 - sd / aa, 0.0, 1.0); + if (coverage <= 0.001) discard; + outColor = vec4(u_color.rgb * u_color.a, u_color.a) * coverage; +}`; +const PICK_VS = `#version 300 es +in float ax; in float ay; in float a_sval; +uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; +uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform float u_dpr; +flat out int v_id; +${AXIS_GLSL} +void main() { + gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); + float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; + gl_PointSize = max(sz, 6.0) * u_dpr; // enlarge hit target + v_id = gl_VertexID; +}`; +const PICK_FS = `#version 300 es +precision highp float; precision highp int; +uniform int u_pick_base; +flat in int v_id; +out vec4 outColor; +void main() { + vec2 d = gl_PointCoord - 0.5; + if (length(d) > 0.5) discard; + int id = u_pick_base + v_id; + outColor = vec4( + float(id & 255) / 255.0, + float((id >> 8) & 255) / 255.0, + float((id >> 16) & 255) / 255.0, + float((id >> 24) & 255) / 255.0 + ); +}`; +const GRID_VS = `#version 300 es +in vec2 a_corner; +uniform vec4 u_view; // x0,x1,y0,y1 +uniform int u_xmode; uniform int u_ymode; +out vec2 v_data; +${AXIS_GLSL} +void main() { + gl_Position = vec4(a_corner * 2.0 - 1.0, 0.0, 1.0); + float x = mix(fcViewCoord(u_view.x, u_xmode), fcViewCoord(u_view.y, u_xmode), a_corner.x); + float y = mix(fcViewCoord(u_view.z, u_ymode), fcViewCoord(u_view.w, u_ymode), a_corner.y); + v_data = vec2(fcViewValue(x, u_xmode), fcViewValue(y, u_ymode)); +}`; +const DENSITY_FS = `#version 300 es +precision highp float; +uniform sampler2D u_grid; uniform sampler2D u_lut; +uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1 +uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor; +in vec2 v_data; +out vec4 outColor; +void main() { + vec2 uv = vec2((v_data.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), + (v_data.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; + float t = texture(u_grid, uv).r; + if (t <= 0.0) discard; + vec4 paint = u_constantColor == 1 + ? u_color + : texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5)); + vec3 rgb = paint.rgb; + float alpha = u_opacity * paint.a * clamp(t * 1.35, 0.0, 1.0); + if (alpha <= 0.01) discard; + outColor = vec4(rgb * alpha, alpha); +}`; +const HEATMAP_FS = `#version 300 es +precision highp float; +uniform sampler2D u_grid; uniform sampler2D u_lut; +uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1 +uniform float u_opacity; +uniform int u_truecolor; +in vec2 v_data; +out vec4 outColor; +void main() { + vec2 uv = vec2((v_data.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), + (v_data.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; + vec4 sampled = texture(u_grid, uv); + if (u_truecolor == 1) { + float alpha = sampled.a * u_opacity; + if (alpha <= 0.0) discard; + outColor = vec4(sampled.rgb * alpha, alpha); + return; + } + float raw = sampled.r; + if (raw <= 0.0) discard; + float t = clamp((raw * 255.0 - 1.0) / 254.0, 0.0, 1.0); + vec3 rgb = texture(u_lut, vec2(t, 0.5)).rgb; + outColor = vec4(rgb * u_opacity, u_opacity); +}`; +const LINE_VS = `#version 300 es +in float ax0; in float ay0; in float ax1; in float ay1; +uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; +uniform int u_colorMode; +uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; +in float a_len0; in float a_len1; +out float v_off; out float v_dash; +const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); +${AXIS_GLSL} +void main() { + vec2 p0 = vec2(fcMap(ax0, u_xmap, u_xmeta, u_xmode), fcMap(ay0, u_ymap, u_ymeta, u_ymode)); + vec2 p1 = vec2(fcMap(ax1, u_xmap, u_xmeta, u_xmode), fcMap(ay1, u_ymap, u_ymeta, u_ymode)); + vec2 pix0 = (p0 * 0.5 + 0.5) * u_res; + vec2 pix1 = (p1 * 0.5 + 0.5) * u_res; + vec2 dir = pix1 - pix0; + float len = max(length(dir), 1e-6); + dir /= len; + vec2 n = vec2(-dir.y, dir.x); + vec2 c = corners[gl_VertexID]; + float half_w = u_width * 0.5 + 0.5; + vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; + gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); + v_off = c.y * half_w; + // Cumulative screen-space arc length at this fragment (device px), fed from + // CPU-computed per-vertex lengths so dashes stay continuous across segments + // and constant on screen through zoom. + v_dash = mix(a_len0, a_len1, c.x); +}`; +const LINE_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; uniform float u_width; +uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; +in float v_off; in float v_dash; +out vec4 outColor; +void main() { + float half_w = u_width * 0.5; + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + if (u_dashCount > 0) { + float m = mod(v_dash, u_dashPeriod); + float acc = 0.0; + float on = 0.0; + for (int i = 0; i < 8; i++) { + if (i >= u_dashCount) break; + float next = acc + u_dashArr[i]; + if (m < next) { + // 0.6px feather at each dash start/end so edges aren't aliased. + float d = min(m - acc, next - m); + on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0); + break; + } + acc = next; + } + alpha *= on; + } + if (alpha <= 0.001) discard; + outColor = vec4(u_color.rgb * alpha, alpha); +}`; +const SEGMENT_VS = `#version 300 es +in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; +in float a_dash0; in float a_dashDir; +uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; +uniform int u_colorMode; +uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; +uniform int u_x0mode; uniform int u_x1mode; uniform int u_y0mode; uniform int u_y1mode; +out float v_off; out float v_cval; out float v_dash; +const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); +${AXIS_GLSL} +void main() { + vec2 p0 = vec2(fcMap(ax0, u_xmap, u_x0meta, u_x0mode), fcMap(ay0, u_ymap, u_y0meta, u_y0mode)); + vec2 p1 = vec2(fcMap(ax1, u_xmap, u_x1meta, u_x1mode), fcMap(ay1, u_ymap, u_y1meta, u_y1mode)); + vec2 pix0 = (p0 * 0.5 + 0.5) * u_res; + vec2 pix1 = (p1 * 0.5 + 0.5) * u_res; + vec2 dir = pix1 - pix0; + float len = max(length(dir), 1e-6); + dir /= len; + vec2 n = vec2(-dir.y, dir.x); + vec2 c = corners[gl_VertexID]; + float half_w = u_width * 0.5 + 0.5; + vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; + gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); + v_off = c.y * half_w; + v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + v_dash = a_dash0 + c.x * len * a_dashDir; +}`; +const SEGMENT_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; +uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; +in float v_off; in float v_cval; in float v_dash; +out vec4 outColor; +void main() { + float half_w = u_width * 0.5; + vec3 rgb = u_colorMode != 0 ? texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb : u_color.rgb; + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + if (u_dashCount > 0) { + float m = mod(v_dash, u_dashPeriod); + float acc = 0.0; + float on = 0.0; + for (int i = 0; i < 8; i++) { + if (i >= u_dashCount) break; + float next = acc + u_dashArr[i]; + if (m < next) { on = (i % 2 == 0) ? 1.0 : 0.0; break; } + acc = next; + } + alpha *= on; + } + if (alpha <= 0.001) discard; + outColor = vec4(rgb * alpha, alpha); +}`; +const MESH_VS = `#version 300 es +in float ax0; in float ay0; in float ax1; in float ay1; in float ax2; in float ay2; in float a_cval; +uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_x2meta; +uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_y2meta; +uniform int u_x0mode; uniform int u_x1mode; uniform int u_x2mode; +uniform int u_y0mode; uniform int u_y1mode; uniform int u_y2mode; +uniform int u_colorMode; +out float v_cval; out vec3 v_bary; +${AXIS_GLSL} +void main() { + int vertex = gl_VertexID % 3; + float x = vertex == 0 ? ax0 : (vertex == 1 ? ax1 : ax2); + float y = vertex == 0 ? ay0 : (vertex == 1 ? ay1 : ay2); + vec2 xm = vertex == 0 ? u_x0meta : (vertex == 1 ? u_x1meta : u_x2meta); + vec2 ym = vertex == 0 ? u_y0meta : (vertex == 1 ? u_y1meta : u_y2meta); + int xmode = vertex == 0 ? u_x0mode : (vertex == 1 ? u_x1mode : u_x2mode); + int ymode = vertex == 0 ? u_y0mode : (vertex == 1 ? u_y1mode : u_y2mode); + gl_Position = vec4(fcMap(x, u_xmap, xm, xmode), fcMap(y, u_ymap, ym, ymode), 0.0, 1.0); + v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + v_bary = vertex == 0 ? vec3(1.,0.,0.) : (vertex == 1 ? vec3(0.,1.,0.) : vec3(0.,0.,1.)); +}`; +const MESH_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; +uniform vec4 u_stroke; uniform float u_strokeWidth; +in float v_cval; in vec3 v_bary; +out vec4 outColor; +void main() { + vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb; + vec4 fill = vec4(rgb * u_opacity, u_opacity); + if (u_strokeWidth > 0.0) { + float edge = min(v_bary.x, min(v_bary.y, v_bary.z)); + float coverage = smoothstep(0.0, max(fwidth(edge) * u_strokeWidth, 1e-5), edge); + outColor = mix(u_stroke, fill, coverage); + } else { + outColor = fill; + } +}`; +const GRAD_GLSL = ` +uniform int u_gradMode; uniform int u_gradDir; uniform int u_gradCount; +uniform float u_gradPos[8]; uniform vec4 u_gradColor[8]; +vec4 fcGradSample(float t) { + vec4 c0 = u_gradColor[0]; float p0 = u_gradPos[0]; + if (t <= p0) return c0; + for (int i = 1; i < 8; i++) { + if (i >= u_gradCount) break; + float p1 = u_gradPos[i]; vec4 c1 = u_gradColor[i]; + if (t <= p1) return mix(c0, c1, (t - p0) / max(p1 - p0, 1e-6)); + p0 = p1; c0 = c1; + } + return c0; +} +float fcGradT(float markT, vec2 res) { + float t; + if (u_gradMode == 2) { + vec2 f = gl_FragCoord.xy / max(res, vec2(1.0)); + t = u_gradDir == 0 ? 1.0 - f.y : u_gradDir == 1 ? f.y : u_gradDir == 2 ? 1.0 - f.x : f.x; + } else { + t = u_gradDir == 0 ? 1.0 - markT : markT; + } + return clamp(t, 0.0, 1.0); +}`; +const AREA_VS = `#version 300 es +in float ax0; in float ax1; in float ay0; in float ay1; in float ab0; in float ab1; +uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_bmap; +uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform vec2 u_bmeta; +uniform int u_xmode; uniform int u_ymode; +out float v_top; out float v_base; out float v_pos; +const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); +${AXIS_GLSL} +void main() { + vec2 c = corners[gl_VertexID]; + float x0 = fcMap(ax0, u_xmap, u_xmeta, u_xmode); + float x1 = fcMap(ax1, u_xmap, u_xmeta, u_xmode); + float y0 = fcMap(ay0, u_ymap, u_ymeta, u_ymode); + float y1 = fcMap(ay1, u_ymap, u_ymeta, u_ymode); + float b0 = fcMap(ab0, u_bmap, u_bmeta, u_ymode); + float b1 = fcMap(ab1, u_bmap, u_bmeta, u_ymode); + float top = mix(y0, y1, c.x); + float base = mix(b0, b1, c.x); + float clipY = mix(base, top, c.y); + // Carry the curve top, baseline, and this fragment's Y *separately* (each is + // linear in x and continuous across segments); the fragment divides them for + // a true per-column height fraction. Interpolating the ratio itself (the old + // c.y) facets over the slanted-top quad and streaks — this doesn't, and the + // fill stays evenly saturated at the curve whatever its height. + v_top = top; + v_base = base; + v_pos = clipY; + gl_Position = vec4(mix(x0, x1, c.x), clipY, 0.0, 1.0); +}`; +const AREA_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; +uniform vec2 u_res; +in float v_top; in float v_base; in float v_pos; +out vec4 outColor; +${GRAD_GLSL} +void main() { + vec4 premult = vec4(u_color.rgb * u_color.a, u_color.a); + if (u_gradMode != 0) { + // 0 at the baseline, 1 exactly at the curve — even at the curve everywhere. + float denom = v_top - v_base; + float markT = clamp((v_pos - v_base) / (abs(denom) > 1e-6 ? denom : 1e-6), 0.0, 1.0); + // Compose the mark opacity (premultiplied) over the gradient sample. + premult = fcGradSample(fcGradT(markT, u_res)) * u_color.a; + } + if (premult.a <= 0.001) discard; + outColor = premult; +}`; +const RECT_VS = `#version 300 es +in float ax0; in float ax1; in float ay0; in float ay1; +uniform vec2 u_x0map; uniform vec2 u_x1map; uniform vec2 u_y0map; uniform vec2 u_y1map; +uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; +uniform int u_xmode; uniform int u_ymode; +uniform vec4 u_edgePad; +uniform vec2 u_res; +in float a_cval; uniform int u_colorMode; +out float v_lutCoord; +out vec2 v_local; out vec2 v_half; out float v_t; +const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); +${AXIS_GLSL} +void main() { + vec2 c = corners[gl_VertexID]; + float x0 = fcMap(ax0, u_x0map, u_x0meta, u_xmode) + u_edgePad.x; + float x1 = fcMap(ax1, u_x1map, u_x1meta, u_xmode) + u_edgePad.y; + float y0 = fcMap(ay0, u_y0map, u_y0meta, u_ymode) + u_edgePad.z; + float y1 = fcMap(ay1, u_y1map, u_y1meta, u_ymode) + u_edgePad.w; + v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + // Pixel-space local frame for the rounded-corner/stroke SDF (v_half is + // constant across the quad; v_local interpolates to the fragment offset). + vec2 pA = (vec2(x0, y0) * 0.5 + 0.5) * u_res; + vec2 pB = (vec2(x1, y1) * 0.5 + 0.5) * u_res; + v_half = abs(pB - pA) * 0.5; + v_local = mix(pA, pB, c) - (pA + pB) * 0.5; + v_t = c.y; + gl_Position = vec4(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0); +}`; +const BAR_VS = `#version 300 es +in float a_pos; in float a_v0; in float a_v1; in float a_cval; +uniform vec2 u_pmap; uniform vec2 u_v0map; uniform vec2 u_v1map; +uniform vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; +uniform int u_pmode; uniform int u_vmode; +uniform float u_width; uniform int u_orientation; uniform int u_v0Mode; uniform float u_v0Const; +uniform float u_v0EdgePad; +uniform vec2 u_res; +uniform int u_colorMode; +out float v_lutCoord; +out vec2 v_local; out vec2 v_half; out float v_t; +const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); +${AXIS_GLSL} +void main() { + vec2 c = corners[gl_VertexID]; + float p = fcMap(a_pos, u_pmap, u_pmeta, u_pmode); + float halfW = abs(u_width * u_pmap.x) * 0.5; + float v0 = (u_v0Mode == 0 ? u_v0Const : fcMap(a_v0, u_v0map, u_v0meta, u_vmode)) + u_v0EdgePad; + float v1 = fcMap(a_v1, u_v1map, u_v1meta, u_vmode); + v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; + vec2 clipA, clipB; + if (u_orientation == 0) { + clipA = vec2(p - halfW, v0); clipB = vec2(p + halfW, v1); + gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); + v_t = c.y; + } else { + clipA = vec2(v0, p - halfW); clipB = vec2(v1, p + halfW); + gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); + v_t = c.x; + } + // Pixel-space local frame for the rounded-corner/stroke SDF; v_t runs along + // the value axis (0 at the base, 1 at the bar tip) for mark-space gradients. + vec2 pA = (clipA * 0.5 + 0.5) * u_res; + vec2 pB = (clipB * 0.5 + 0.5) * u_res; + v_half = abs(pB - pA) * 0.5; + v_local = vec2(mix(pA.x, pB.x, c.x), mix(pA.y, pB.y, c.y)) - (pA + pB) * 0.5; +}`; +const RECT_FS = `#version 300 es +precision highp float; precision highp int; +uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; +uniform vec2 u_radius; uniform float u_strokeWidth; uniform vec4 u_stroke; +uniform vec2 u_res; +in float v_lutCoord; +in vec2 v_local; in vec2 v_half; in float v_t; +out vec4 outColor; +${GRAD_GLSL} +void main() { + vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; + vec4 premult = vec4(rgb * u_color.a, u_color.a); + // Compose the mark opacity (u_color.a) over the gradient — premultiplied, so + // one scalar multiply fades every stop, including a fade-to-transparent. + if (u_gradMode != 0) premult = fcGradSample(fcGradT(v_t, u_res)) * u_color.a; + if (u_radius.x > 0.0 || u_radius.y > 0.0 || u_strokeWidth > 0.0) { + // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so + // corner_radius=(6, 0) rounds only the value end of the bar. On the + // straight sides the SDF reduces to |local|-half independent of r, so + // differing radii meet with no seam. + float r = min(v_t > 0.5 ? u_radius.x : u_radius.y, min(v_half.x, v_half.y)); + vec2 q = abs(v_local) - (v_half - vec2(r)); + float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; + float aa = 0.75; + if (u_strokeWidth > 0.0) { + float inner = 1.0 - smoothstep(-aa, aa, d + u_strokeWidth); + premult = mix(u_stroke, premult, inner); + } + premult *= 1.0 - smoothstep(-aa, aa, d); + } + if (premult.a <= 0.001) discard; + outColor = premult; +}`; +function fcMonotoneTangents(x, y, n) { +const d = new Float64Array(n - 1); +const m = new Float64Array(n); +for (let i = 0; i < n - 1; i++) { +const dx = x[i + 1] - x[i]; +d[i] = dx > 0 ? (y[i + 1] - y[i]) / dx : 0; +} +m[0] = d[0]; +m[n - 1] = d[n - 2]; +for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) * 0.5; +for (let i = 0; i < n - 1; i++) { +if (d[i] === 0) { m[i] = 0; m[i + 1] = 0; continue; } +const a = m[i] / d[i]; +const b = m[i + 1] / d[i]; +const s = a * a + b * b; +if (s > 9) { +const t = 3 / Math.sqrt(s); +m[i] = t * a * d[i]; +m[i + 1] = t * b * d[i]; +} +} +return m; +} +function fcSmoothResample(x, y, extra, n, maxOut) { +if (n < 3) return null; +const sub = Math.max(1, Math.min(16, Math.floor(maxOut / n))); +if (sub <= 1) return null; +for (let i = 0; i < n; i++) { +if (!Number.isFinite(x[i]) || !Number.isFinite(y[i])) return null; +if (i > 0 && x[i] < x[i - 1]) return null; +if (extra && !Number.isFinite(extra[i])) return null; +} +const my = fcMonotoneTangents(x, y, n); +const me = extra ? fcMonotoneTangents(x, extra, n) : null; +const outN = (n - 1) * sub + 1; +const ox = new Float32Array(outN); +const oy = new Float32Array(outN); +const oe = extra ? new Float32Array(outN) : null; +let k = 0; +for (let i = 0; i < n - 1; i++) { +const h = x[i + 1] - x[i]; +for (let s = 0; s < sub; s++) { +const t = s / sub; +ox[k] = x[i] + h * t; +if (h > 0) { +const t2 = t * t; +const t3 = t2 * t; +const h00 = 2.0 * t3 - 3.0 * t2 + 1.0; +const h10 = t3 - 2.0 * t2 + t; +const h01 = -2.0 * t3 + 3.0 * t2; +const h11 = t3 - t2; +oy[k] = h00 * y[i] + h10 * h * my[i] + h01 * y[i + 1] + h11 * h * my[i + 1]; +if (oe) oe[k] = h00 * extra[i] + h10 * h * me[i] + h01 * extra[i + 1] + h11 * h * me[i + 1]; +} else { +oy[k] = y[i]; +if (oe) oe[k] = extra[i]; +} +k++; +} +} +ox[k] = x[n - 1]; +oy[k] = y[n - 1]; +if (oe) oe[k] = extra[n - 1]; +return { x: ox, y: oy, extra: oe, n: outN }; +} +const LOD_DIRECT_POINT_BUDGET = 200000; +const LOD_DRILL_EXIT_FACTOR = 1.15; +function lodFade(view, start, duration = 140) { +if (start === undefined || start === null || duration <= 0 || view._prefersReducedMotion()) { +return 1; +} +const t = Math.min(1, Math.max(0, (view._now() - start) / duration)); +return t * t * (3 - 2 * t); +} +function lodDecodeLogU8(buf, maxVal) { +const u8 = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +const out = new Float32Array(u8.length); +const denom = Math.log1p(Math.max(0, maxVal || 0)); +if (denom > 0) { +for (let i = 0; i < u8.length; i++) { +if (u8[i] > 0) out[i] = Math.expm1((u8[i] / 255) * denom); +} +} +return out; +} +function lodCopyGrid(f32) { +return f32.slice ? f32.slice() : new Float32Array(f32); +} +function lodWriteGridTexture(gl, tex, f32, w, h, maxVal) { +const data = new Uint8Array(f32.length); +const denom = Math.log1p(Math.max(0, maxVal || 0)); +if (denom > 0) { +for (let i = 0; i < f32.length; i++) { +const c = f32[i]; +if (c > 0 && Number.isFinite(c)) { +data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); +} +} +} +gl.bindTexture(gl.TEXTURE_2D, tex); +const align = gl.getParameter(gl.UNPACK_ALIGNMENT); +gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); +gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +} +function lodNormMax(g, nextMax) { +if (!Number.isFinite(nextMax) || nextMax <= 0) { +g.densityNormMax = 0; +return 0; +} +const prev = Number.isFinite(g.densityNormMax) && g.densityNormMax > 0 +? g.densityNormMax +: nextMax; +const norm = nextMax > prev +? prev * 0.3 + nextMax * 0.7 +: Math.max(nextMax, prev * 0.86); +g.densityNormMax = norm; +return norm; +} +function lodStartNormAnim(view, g, start, target) { +if (!g.density || !g.density.grid || !Number.isFinite(target) || target <= 0) { +g._densityNormAnim = null; +return; +} +const ratio = Math.abs(Math.log(Math.max(start, 1e-12) / Math.max(target, 1e-12))); +if (view._prefersReducedMotion() || ratio < 0.02) { +g._densityNormAnim = null; +g.density.normMax = target; +g.densityNormMax = target; +lodWriteGridTexture(view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target); +return; +} +g._densityNormAnim = { +start, +target, +startedAt: view._now(), +duration: target < start ? 420 : 260, +}; +} +function lodStepNorm(view, g) { +const anim = g._densityNormAnim; +const d = g.density; +if (!anim || !d || !d.grid || !d.tex) return; +const t = Math.min(1, Math.max(0, (view._now() - anim.startedAt) / anim.duration)); +const k = t * t * (3 - 2 * t); +const norm = anim.start + (anim.target - anim.start) * k; +const prev = d.normMax || 0; +const rel = Math.abs(norm - prev) / Math.max(Math.abs(norm), Math.abs(prev), 1); +if (rel > 0.004 || t >= 1) { +d.normMax = norm; +g.densityNormMax = norm; +lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm); +} +if (t < 1) { +view.draw(); +return; +} +d.normMax = anim.target; +g.densityNormMax = anim.target; +g._densityNormAnim = null; +} +function lodDensityArea(d) { +return Math.abs((d.xRange[1] - d.xRange[0]) * (d.yRange[1] - d.yRange[0])); +} +function lodWindowArea(win) { +if (!win) return 0; +return Math.abs((win.x1 - win.x0) * (win.y1 - win.y0)); +} +function lodWindowCenterInside(win, view) { +if (!win || !view) return false; +const cx = (view.x0 + view.x1) / 2; +const cy = (view.y0 + view.y1) / 2; +return ( +cx >= Math.min(win.x0, win.x1) && +cx <= Math.max(win.x0, win.x1) && +cy >= Math.min(win.y0, win.y1) && +cy <= Math.max(win.y0, win.y1) +); +} +function lodDensityForView(view, g) { +const cache = g.densityCache || (g.density ? [g.density] : []); +let best = null; +let broadest = null; +for (const d of cache) { +if (!d || !d.tex) continue; +if (!broadest || lodDensityArea(d) > lodDensityArea(broadest)) broadest = d; +if (!view._viewInsideRange(d.xRange, d.yRange)) continue; +if (!best || lodDensityArea(d) < lodDensityArea(best)) best = d; +} +return best || broadest || g.density; +} +function lodHoldPendingDrill(view, g, d) { +const pending = g._lodPendingView; +if (!d || !pending || g._drillDying) return false; +if (g._lodPendingSeq !== view.seq) return false; +if (g._lodPendingAt && view._now() - g._lodPendingAt > 1200) return false; +if (!lodWindowCenterInside(d.win, pending)) return false; +const drillArea = lodWindowArea(d.win); +const pendingArea = lodWindowArea(pending); +if (!Number.isFinite(drillArea) || !Number.isFinite(pendingArea) || drillArea <= 0) return false; +const baseVisible = Number.isFinite(d.visible) ? d.visible : d.n; +if (!Number.isFinite(baseVisible) || baseVisible <= 0) return false; +const estimatedVisible = baseVisible * Math.max(1, pendingArea / drillArea); +return estimatedVisible <= LOD_DIRECT_POINT_BUDGET * LOD_DRILL_EXIT_FACTOR; +} +function lodRememberDensity(view, g, d) { +if (!d || !d.tex) return; +d._stamp = ++view._densityStamp; +if (!g.densityCache) g.densityCache = []; +if (!g.densityCache.includes(d)) g.densityCache.push(d); +const maxCached = 8; +while (g.densityCache.length > maxCached) { +let drop = -1; +for (let i = 0; i < g.densityCache.length; i++) { +const cand = g.densityCache[i]; +if (cand === g.density) continue; +if (cand === g.prevDensity) continue; +if (cand === g._densitySwitchPrev) continue; +if (drop < 0) { drop = i; continue; } +const dropArea = lodDensityArea(g.densityCache[drop]); +const candArea = lodDensityArea(cand); +if (candArea < dropArea || (candArea === dropArea && cand._stamp < g.densityCache[drop]._stamp)) { +drop = i; +} +} +if (drop < 0) break; +const old = g.densityCache.splice(drop, 1)[0]; +if (old !== g.density && old !== g.prevDensity && old !== g._densitySwitchPrev) { +view.gl.deleteTexture(old.tex); +} +} +} +function lodApplyDrill(view, g, upd, buffers) { +const gl = view.gl; +const fresh = !g.drill; +let d = g.drill; +if (!d) { +d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; +} +d.xAxis = g.xAxis; +d.yAxis = g.yAxis; +gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf); +gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.x.buf]), gl.STATIC_DRAW); +gl.bindBuffer(gl.ARRAY_BUFFER, d.yBuf); +gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.y.buf]), gl.STATIC_DRAW); +d.xMeta = { offset: upd.x.offset, scale: upd.x.scale }; +d.yMeta = { offset: upd.y.offset, scale: upd.y.scale }; +d.win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1] }; +d.n = Math.min(upd.x.len, upd.y.len); +d.visible = upd.visible ?? d.n; +d.seq = upd.drill_seq; +d.selActive = false; +view._hoverId = -1; +view._lastRow = null; +d.colorMode = 0; +d.color = parseColor(view.root, upd.color && upd.color.color, [0.3, 0.47, 0.66, 1]); +if (upd.color && upd.color.buf !== undefined) { +d.colorMode = upd.color.mode === "continuous" ? 1 : 2; +if (!d.cBuf) d.cBuf = gl.createBuffer(); +const colorValues = upd.color.dtype === "u8" +? view._asU8(buffers[upd.color.buf]) +: view._asF32(buffers[upd.color.buf]); +d.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, d.cBuf); +gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +d.lut = upd.color.mode === "continuous" +? view._lut(upd.color.colormap) +: view._paletteLut(upd.color.palette); +} +d.sizeMode = 0; +d.size = (upd.size && upd.size.size) || 4.0; +d.sizeRange = [2, 18]; +if (upd.size && upd.size.mode === "continuous") { +d.sizeMode = 1; +if (!d.sBuf) d.sBuf = gl.createBuffer(); +gl.bindBuffer(gl.ARRAY_BUFFER, d.sBuf); +gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.size.buf]), gl.STATIC_DRAW); +d.sizeRange = upd.size.range_px; +} +if (upd.density_val && upd.density_val.buf !== undefined) { +if (!d.dBuf) d.dBuf = gl.createBuffer(); +gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); +gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.density_val.buf]), gl.STATIC_DRAW); +d.dlut = view._lut(upd.density_colormap || "viridis"); +const first = d.lodBlend === undefined; +d.lodBlend = Math.min(1, upd.lod_blend ?? 0); +if (first) d.lodBlendShown = d.lodBlend; +} else { +d.lodBlend = 0; +} +if (fresh) { +g._drillFadeStart = view._now(); +g._drillWasInside = false; +g._drillShownAlpha = 0; +g._drillExitFadeStart = null; +g._drillDying = false; +g._drillDiedInsideWin = false; +return; +} +if (g._drillDying || g._drillExitFadeStart != null) { +lodEnterDrillContinuous(view, g); +} +g._drillDying = false; +g._drillDiedInsideWin = false; +} +function lodDropDrill(view, g) { +const d = g.drill; +if (!d) return; +const gl = view.gl; +view._deleteVaos(d); +for (const b of [d.xBuf, d.yBuf, d.cBuf, d.sBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); +g.drill = null; +g._drillFadeStart = null; +g._drillExitFadeStart = null; +g._drillWasInside = false; +g._drillShownAlpha = null; +g._drillDying = false; +g._drillDiedInsideWin = false; +view._hoverId = -1; +view._lastRow = null; +} +function lodMarkDrillDying(view, g) { +if (!g.drill) return; +g._drillDying = true; +g._drillDiedInsideWin = view._viewInside(g.drill.win); +lodBeginDrillExitContinuous(view, g); +} +function lodDrillExitFade(view, g) { +if (g._drillExitFadeStart === undefined || g._drillExitFadeStart === null) { +g._drillExitFadeStart = view._now(); +} +const fade = lodFade(view, g._drillExitFadeStart, LOD_EXIT_FADE_MS); +if (fade >= 1) g._drillExitFadeStart = null; +return fade; +} +const LOD_ENTRY_FADE_MS = 140; +const LOD_EXIT_FADE_MS = 120; +function lodFadeInvert(alpha) { +const a = Math.min(1, Math.max(0, alpha)); +return 0.5 - Math.sin(Math.asin(1 - 2 * a) / 3); +} +function lodDrillShownAlpha(view, g) { +if (g._drillExitFadeStart != null) { +return 1 - lodFade(view, g._drillExitFadeStart, LOD_EXIT_FADE_MS); +} +if (g._drillFadeStart != null) { +return lodFade(view, g._drillFadeStart, LOD_ENTRY_FADE_MS); +} +if (g._drillShownAlpha != null) return g._drillShownAlpha; +return g._drillWasInside ? 1 : 0; +} +function lodEnterDrillContinuous(view, g) { +const alpha = lodDrillShownAlpha(view, g); +g._drillShownAlpha = alpha; +g._drillExitFadeStart = null; +g._drillFadeStart = +alpha >= 1 ? null : view._now() - LOD_ENTRY_FADE_MS * lodFadeInvert(alpha); +} +function lodBeginDrillExitContinuous(view, g) { +if (g._drillExitFadeStart != null) return; +const alpha = lodDrillShownAlpha(view, g); +g._drillShownAlpha = alpha; +g._drillFadeStart = null; +g._drillExitFadeStart = view._now() - LOD_EXIT_FADE_MS * lodFadeInvert(1 - alpha); +} +function lodApplyDensityUpdate(view, g, upd, buffers) { +lodMarkDrillDying(view, g); +const d = upd.density; +const grid = d.enc === "log-u8" +? lodDecodeLogU8(buffers[d.buf], d.max) +: lodCopyGrid(view._asF32(buffers[d.buf])); +const normStart = lodNormMax(g, d.max); +const normMax = view._prefersReducedMotion() ? d.max : normStart; +g.densityNormMax = normMax; +g.prevDensity = g.density; +g._densityFadeStart = view._now(); +g.density = { +w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, +color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, +xRange: d.x_range, yRange: d.y_range, +grid, +tex: view._uploadGrid(grid, d.w, d.h, normMax), +lut: g.density.lut, +}; +if (Object.prototype.hasOwnProperty.call(d, "sample")) { +view._applyDensitySample(g, d.sample, buffers); +} +lodStartNormAnim(view, g, normMax, d.max); +lodRememberDensity(view, g, g.density); +} +function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { +if (density !== g._shownDensity) { +if (density === g._densitySwitchPrev && g._densitySwitchFadeStart != null) { +const f = lodFade(view, g._densitySwitchFadeStart, 140); +g._densitySwitchFadeStart = view._now() - 140 * lodFadeInvert(1 - f); +} else { +g._densitySwitchFadeStart = view._now(); +} +g._densitySwitchPrev = g._shownDensity; +g._shownDensity = density; +} +const prev = g._densitySwitchPrev; +const fade = prev && prev.tex ? lodFade(view, g._densitySwitchFadeStart, 140) : 1; +if (fade < 1) { +view._drawDensity(g, prev, (1 - fade) * opacityScale); +view._drawDensity(g, density, fade * opacityScale); +view.draw(); +return; +} +if (fade >= 1) { +if (g.prevDensity === g._densitySwitchPrev) g.prevDensity = null; +g._densitySwitchPrev = null; +g._densitySwitchFadeStart = null; +if (density === g.density) g._densityFadeStart = null; +} +view._drawDensity(g, density, opacityScale); +} +function lodDrawDensityTier(view, g, x0, x1, y0, y1) { +lodStepNorm(view, g); +const d = g.drill; +if (d && g._drillDying && !g._drillDiedInsideWin && view._viewInside(d.win)) { +g._drillDying = false; +lodEnterDrillContinuous(view, g); +g._drillWasInside = true; +} +const inside = d && !g._drillDying && view._viewInside(d.win); +const density = lodDensityForView(view, g); +if (inside) { +if (!g._drillWasInside || g._drillExitFadeStart != null) lodEnterDrillContinuous(view, g); +g._drillWasInside = true; +g._drillExitFadeStart = null; +const fade = lodFade(view, g._drillFadeStart); +g._drillShownAlpha = fade; +g._shownDensity = fade < 1 ? density : null; +g._densitySwitchPrev = null; +g._densitySwitchFadeStart = null; +if (fade < 1 && density && density.tex) { +view._drawDensity(g, density, 1 - fade); +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis), +fade +); +view.draw(); +} else { +g._drillFadeStart = null; +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis) +); +} +} else if (density && density.tex) { +if (lodHoldPendingDrill(view, g, d)) { +lodEnterDrillContinuous(view, g); +const fade = lodFade(view, g._drillFadeStart); +g._drillShownAlpha = fade; +if (fade < 1) { +view._drawDensity(g, density, 1 - fade); +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis), +fade +); +view.draw(); +} else { +g._drillFadeStart = null; +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis) +); +} +if (view._viewAnim) view.draw(); +return; +} +const exitingDrill = d && g._drillWasInside; +if (exitingDrill) lodBeginDrillExitContinuous(view, g); +const exitFade = exitingDrill ? lodDrillExitFade(view, g) : 1; +if (d) g._drillShownAlpha = exitingDrill && exitFade < 1 ? 1 - exitFade : 0; +if (exitingDrill && exitFade < 1) { +lodDrawDensityWithFade(view, g, density, exitFade); +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis), +1 - exitFade +); +view.draw(); +} else { +if (g._drillDying) lodDropDrill(view, g); +else if (exitingDrill) g._drillWasInside = false; +lodDrawDensityWithFade(view, g, density); +view._drawDensitySample(g, x0, x1, y0, y1); +} +} else if (d) { +view._drawPoints( +d, +view._map(d.xMeta, x0, x1, d.xAxis), +view._map(d.yMeta, y0, y1, d.yAxis) +); +} +} +const FC_REBIN_WORKER_SRC = ` +const DATA = new Map(); +self.onmessage = (e) => { + const m = e.data; + if (m.type === "init") { + DATA.set(m.trace, { x: new Float64Array(m.x), y: new Float64Array(m.y) }); + return; + } + const d = DATA.get(m.trace); + if (!d) return; + const w = m.w, h = m.h; + const grid = new Float32Array(w * h); + const sx = w / ((m.x1 - m.x0) || 1); + const sy = h / ((m.y1 - m.y0) || 1); + let max = 0; + const X = d.x, Y = d.y, n = X.length; + for (let i = 0; i < n; i++) { + const cx = (X[i] - m.x0) * sx; + const cy = (Y[i] - m.y0) * sy; + if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; + const v = ++grid[(cy | 0) * w + (cx | 0)]; + if (v > max) max = v; + } + self.postMessage( + { type: "grid", seq: m.seq, trace: m.trace, w, h, max, + x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer }, + [grid.buffer] + ); +}; +`; +function fcCreateRebinWorker() { +try { +const url = URL.createObjectURL( +new Blob([FC_REBIN_WORKER_SRC], { type: "application/javascript" }) +); +const worker = new Worker(url); +worker._fcUrl = url; +return worker; +} catch (e) { +return null; +} +} +const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; +const COLORBAR_THICKNESS = 18; +const COLORBAR_GAP = 24; +const UNITLESS_STYLE_PROPS = new Set([ +"animation-iteration-count", +"aspect-ratio", +"border-image-outset", +"border-image-slice", +"border-image-width", +"column-count", +"flex", +"flex-grow", +"flex-shrink", +"font-weight", +"line-height", +"opacity", +"order", +"orphans", +"tab-size", +"widows", +"z-index", +"zoom", +"fill-opacity", +"flood-opacity", +"stop-opacity", +"stroke-miterlimit", +"stroke-opacity", +]); +const FC_CONTEXT_GOVERNOR = { +views: new Set(), +seq: 1, +budget() { +const v = typeof window !== "undefined" ? window.XY_CONTEXT_BUDGET : null; +return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; +}, +register(view) { +this.views.add(view); +}, +unregister(view) { +view._ctxPendingReservation = false; +this.views.delete(view); +}, +reserve(requester) { +const live = []; +let pending = 0; +for (const view of this.views) { +if (view !== requester && view.gl && !view._glLost && !view._destroyed) live.push(view); +if (view !== requester && view._ctxPendingReservation && !view._destroyed) pending += 1; +} +const needsReservation = !requester._ctxPendingReservation; +requester._ctxPendingReservation = true; +let over = live.length + pending + (needsReservation ? 1 : 0) - this.budget(); +if (over <= 0) return; +const candidates = live +.filter((view) => !view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of candidates) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} +}, +acquired(requester) { +requester._ctxPendingReservation = false; +}, +cancel(requester) { +requester._ctxPendingReservation = false; +}, +}; +function fcInitiallyVisible(el) { +if (typeof window === "undefined" || !el.getBoundingClientRect) return true; +const rect = el.getBoundingClientRect(); +if (!rect.width && !rect.height) return false; +const vh = window.innerHeight || 0; +const vw = window.innerWidth || 0; +return ( +rect.bottom > -0.25 * vh && rect.top < 1.25 * vh && rect.right > -0.25 * vw && rect.left < 1.25 * vw +); +} +class ChartView { +constructor(el, spec, buffer, comm) { +if (spec.protocol !== PROTOCOL) { +el.textContent = +`xy: protocol mismatch (client speaks ${PROTOCOL}, kernel sent ${spec.protocol}). ` + +"Update the xy package and restart the kernel."; +throw new Error("protocol mismatch"); +} +this.spec = spec; +this.interaction = spec.interaction || {}; +this.markStyle = spec.mark_style || {}; +this.axes = this._normalizeAxes(spec); +this.comm = comm; +this.seq = 0; +this._densityStamp = 0; +this._viewRequestBurstStart = null; +this._viewAnim = null; +this._animRaf = null; +this._wheelZoomRaf = null; +this._pendingWheelZoom = null; +this._lastLabelDraw = null; +this._lutCache = new Map(); +this._listeners = []; +this._glPrograms = []; +this._progCache = new Map(); +this._bufSeq = 0; +this._destroyed = false; +this._hoverId = -1; +this._hoverTarget = null; +this._viewEventRaf = null; +this._linkedSource = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +this.dragMode = "pan"; +this.fluid = spec.width === "100%"; +this.fluidH = spec.height === "100%"; +const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; +const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; +const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; +this.size = { w: Math.max(120, cw), h: Math.max(120, ch) }; +this._layout(); +this._buildDom(el); +this.theme = readTheme(this.root); +this._payload = buffer; +this._glLost = false; +this._ctxReleasedExt = null; +this._ctxReleases = 0; +this._ctxRecoveries = 0; +this._ctxVisible = fcInitiallyVisible(el); +FC_CONTEXT_GOVERNOR.register(this); +if (this._ctxVisible) this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; +this._contextLossCount = 0; +this._contextRestoreCount = 0; +this._contextRecoveryError = null; +this._initGl(buffer); +this.root.dataset.fcContextState = "ready"; +this._initContextLossRecovery(); +this._armContextVisibilityWatch(); +this._initInteraction(); +this._buildModebar(this.root); +if ((this.fluid || this.fluidH) && typeof ResizeObserver !== "undefined") { +this._ro = new ResizeObserver((entries) => { +const r = entries[entries.length - 1].contentRect; +if (r.width || r.height) this._resize(r.width, r.height); +}); +this._ro.observe(this.root); +} +this._armVisibilityResizeWatch(); +this._armDprWatch(); +this.view0 = { +x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], +y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], +}; +this.view = { ...this.view0 }; +this._initLinkedCharts(); +this._themeWatch = window.matchMedia("(prefers-color-scheme: dark)"); +this._onScheme = () => this.refreshTheme(); +this._themeWatch.addEventListener?.("change", this._onScheme); +this._unsubscribeComm = comm ? comm.onMessage((msg, buffers) => this._onKernelMsg(msg, buffers)) : null; +this.draw(); +} +_layout() { +const compact = this.size.w < 520; +const pad = Array.isArray(this.spec.padding) ? this.spec.padding : null; +const marginLeft = pad ? pad[3] : compact ? 46 : MARGIN.l; +const colorbar = this.spec.colorbar; +const verticalColorbar = colorbar && colorbar.orientation !== "horizontal"; +const horizontalColorbar = colorbar && colorbar.orientation === "horizontal"; +const colorbarRightRoom = verticalColorbar ? 86 + (colorbar.label ? 18 : 0) : 0; +const colorbarBottomRoom = horizontalColorbar ? 38 + (colorbar.label ? 16 : 0) : 0; +const marginRight = (pad ? pad[1] : compact ? 8 : MARGIN.r) + colorbarRightRoom; +const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; +const marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; +const topAxisRoom = this._axis("x").side === "top" ? (compact ? 26 : 32) : 0; +const top = marginTop + (this.spec.title ? (compact ? 26 : 30) : 0) + topAxisRoom; +const extraRightAxes = Object.values(this.axes || {}).filter((axis) => +axis && axis.id !== "y" && String(axis.id || "").startsWith("y") && axis.side === "right"); +const right = marginRight + (extraRightAxes.length ? (compact ? 42 : 54) : 0); +this.plot = { +x: marginLeft, +y: top, +w: Math.max(40, this.size.w - marginLeft - right), +h: Math.max(40, this.size.h - top - marginBottom), +}; +} +_normalizeAxes(spec) { +const axes = { ...(spec.axes || {}) }; +if (spec.x_axis) axes.x = spec.x_axis; +if (spec.y_axis) axes.y = spec.y_axis; +for (const [id, axis] of Object.entries(axes)) { +if (axis && typeof axis === "object" && !axis.id) axis.id = id; +} +return axes; +} +_axis(axisId) { +const id = axisId || "x"; +return this.axes[id] || (String(id).startsWith("y") ? this.axes.y : this.axes.x) || {}; +} +_axisDim(axisId) { +return String(axisId || "x").startsWith("y") ? "y" : "x"; +} +_axisMode(axisId) { +return this._axis(axisId).scale === "log" ? 1 : 0; +} +_axisCoord(axis, value) { +const v = Number(value); +if (!Number.isFinite(v)) return NaN; +if (axis && axis.scale === "log") return v > 0 ? Math.log10(v) : NaN; +return v; +} +_axisValue(axis, coord) { +if (axis && axis.scale === "log") return Math.pow(10, coord); +return coord; +} +_axisRange(axisId, view = this.view) { +if (axisId === "x") return [view.x0, view.x1]; +if (axisId === "y") return [view.y0, view.y1]; +const axis = this._axis(axisId); +const r = axis.range || [0, 1]; +return [Number(r[0]), Number(r[1])]; +} +_axisTicks(axisId, target) { +const axis = this._axis(axisId); +const [lo, hi] = this._axisRange(axisId); +if (Array.isArray(axis.tick_values)) { +const ticks = axis.tick_values.map(Number).filter((v) => Number.isFinite(v) && v >= lo && v <= hi); +return { ticks, labels: ticks, step: ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1 }; +} +if (axis.kind === "time") return timeTicks(lo, hi, target); +if (axis.kind === "category") return categoryTicks(lo, hi, axis.categories || [], target); +if (axis.scale === "log") return logTicks(lo, hi, target); +return linearTicks(lo, hi, target); +} +_axisTickText(axis, value, step) { +if (Array.isArray(axis.tick_values) && Array.isArray(axis.tick_labels)) { +const index = axis.tick_values.findIndex((candidate) => Number(candidate) === Number(value)); +if (index >= 0 && index < axis.tick_labels.length) return String(axis.tick_labels[index]); +} +return fmtAxis(axis, value, step); +} +_axisTickTarget(axisId, fallback) { +const axis = this._axis(axisId); +const requested = Number(axis && axis.tick_count); +if (Number.isFinite(requested) && requested > 0) { +return Math.max(1, Math.min(200, requested)); +} +return fallback; +} +_dataPx(axisId, value) { +const dim = this._axisDim(axisId); +const axis = this._axis(axisId); +const [lo, hi] = this._axisRange(axisId); +const c0 = this._axisCoord(axis, lo); +const c1 = this._axisCoord(axis, hi); +const c = this._axisCoord(axis, value); +if (![c0, c1, c].every(Number.isFinite) || c1 === c0) return NaN; +if (dim === "x") return this.plot.x + ((c - c0) / (c1 - c0)) * this.plot.w; +return this.plot.y + (1 - (c - c0) / (c1 - c0)) * this.plot.h; +} +_listen(target, type, handler, options) { +target.addEventListener(type, handler, options); +this._listeners.push({ target, type, handler, options }); +return handler; +} +_interactionFlag(name, fallback = false) { +const value = this.interaction && this.interaction[name]; +return value === undefined ? fallback : value === true; +} +_eventView(source = "view") { +return { +x0: this.view.x0, +x1: this.view.x1, +y0: this.view.y0, +y1: this.view.y1, +source, +}; +} +_dispatchChartEvent(name, detail) { +if (!this.root || typeof CustomEvent !== "function") return; +this.root.dispatchEvent(new CustomEvent(`xy:${name}`, { +detail, +bubbles: true, +composed: true, +})); +} +_emitViewChange(source = "view", opts = {}) { +const shouldDispatch = this._interactionFlag("view_change") || this._linkChannel; +if (!shouldDispatch || this._destroyed) return; +const broadcast = opts.broadcast !== false; +this._pendingViewEvent = { source, broadcast }; +if (this._viewEventRaf) return; +this._viewEventRaf = requestAnimationFrame(() => { +this._viewEventRaf = null; +const pending = this._pendingViewEvent || { source, broadcast }; +this._pendingViewEvent = null; +const detail = this._eventView(pending.source); +if (this._interactionFlag("view_change")) { +this._dispatchChartEvent("view_change", detail); +} +if (this.comm && this._interactionFlag("view_change")) { +this.comm.send({ type: "view_change", ...detail }); +} +if (pending.broadcast) this._broadcastLinkedView(detail); +}); +} +_initLinkedCharts() { +const group = this.interaction && this.interaction.link_group; +if (!group || typeof BroadcastChannel !== "function") return; +this._linkAxes = Array.isArray(this.interaction.link_axes) +? this.interaction.link_axes.filter((axis) => axis === "x" || axis === "y") +: ["x", "y"]; +if (!this._linkAxes.length) this._linkAxes = ["x", "y"]; +this._linkChannel = new BroadcastChannel(`xy:${group}`); +this._linkChannel.onmessage = (event) => { +const msg = event.data || {}; +if (!msg.view || msg.source === this._linkedSource) return; +const next = { ...this.view }; +if (this._linkAxes.includes("x")) { +next.x0 = Number(msg.view.x0); +next.x1 = Number(msg.view.x1); +} +if (this._linkAxes.includes("y")) { +next.y0 = Number(msg.view.y0); +next.y1 = Number(msg.view.y1); +} +if (![next.x0, next.x1, next.y0, next.y1].every(Number.isFinite)) return; +this._setView(next, { animate: false, source: "linked", broadcast: false }); +}; +} +_broadcastLinkedView(detail) { +if (!this._linkChannel) return; +this._linkChannel.postMessage({ source: this._linkedSource, view: detail }); +} +_applyClass(el, className) { +if (typeof className !== "string") return; +for (const token of className.split(/\s+/).filter(Boolean)) { +try { el.classList.add(token); } catch (_) { } +} +} +_stylePropertyName(key) { +if (key.startsWith("--")) return key; +return key.replace(/_/g, "-").replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} +_stylePropertyValue(property, value) { +if (typeof value !== "number") return String(value); +if (!Number.isFinite(value)) return null; +if (property.startsWith("--") || UNITLESS_STYLE_PROPS.has(property)) return String(value); +return `${value}px`; +} +_applyStyle(el, style) { +if (!style || typeof style !== "object" || Array.isArray(style)) return; +for (const [key, value] of Object.entries(style)) { +if (typeof key !== "string") continue; +if (typeof value !== "string" && typeof value !== "number") continue; +const property = this._stylePropertyName(key); +const cssValue = this._stylePropertyValue(property, value); +if (cssValue != null) el.style.setProperty(property, cssValue); +} +} +_applySlot(el, slot) { +if (el && el.dataset) el.dataset.fcSlot = slot; +const dom = this.spec.dom; +if (!dom || typeof dom !== "object") return; +if (slot === "root") this._applyClass(el, dom.class_name); +if (dom.class_names && typeof dom.class_names === "object") { +this._applyClass(el, dom.class_names[slot]); +} +if (slot === "root") this._applyStyle(el, dom.style); +if (dom.styles && typeof dom.styles === "object") { +this._applyStyle(el, dom.styles[slot]); +} +} +_slotStyleValue(slot, property) { +const styles = this.spec.dom?.styles; +const style = styles && typeof styles === "object" ? styles[slot] : null; +if (!style || typeof style !== "object" || Array.isArray(style)) return null; +const want = this._stylePropertyName(property); +for (const key of Object.keys(style)) { +if (this._stylePropertyName(key) === want) return style[key]; +} +return null; +} +_syncContainerSize() { +if (this._destroyed || !(this.fluid || this.fluidH) || !this.root) return; +const rect = this.root.getBoundingClientRect(); +if (rect.width || rect.height) this._resize(rect.width, rect.height); +} +_armVisibilityResizeWatch() { +if (!(this.fluid || this.fluidH)) return; +const syncSoon = () => { +if (this._destroyed) return; +requestAnimationFrame(() => this._syncContainerSize()); +}; +this._listen(window, "resize", syncSoon); +this._listen(window, "pageshow", syncSoon); +this._listen(document, "visibilitychange", syncSoon); +if (typeof IntersectionObserver !== "undefined") { +this._io = new IntersectionObserver((entries) => { +if (entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)) { +syncSoon(); +} +}); +this._io.observe(this.root); +} +} +_markStateValue(state, property, fallback = null) { +const styles = this.markStyle && typeof this.markStyle === "object" ? this.markStyle[state] : null; +if (!styles || typeof styles !== "object" || Array.isArray(styles)) return fallback; +if (Object.prototype.hasOwnProperty.call(styles, property)) return styles[property]; +return fallback; +} +_markStateNumber(state, property, fallback) { +const value = this._markStateValue(state, property, fallback); +if (typeof value !== "number" || !Number.isFinite(value)) return fallback; +return value; +} +_markStatePaint(state, property, fallback) { +const value = this._markStateValue(state, property, fallback); +return typeof value === "string" ? value : fallback; +} +_armDprWatch() { +if (typeof window.matchMedia !== "function") return; +this._dprMq?.removeEventListener?.("change", this._onDprChange); +const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); +this._onDprChange = () => { +if (this._destroyed) return; +this._resize(this.size.w, this.size.h); +this._armDprWatch(); +}; +mq.addEventListener?.("change", this._onDprChange, { once: true }); +this._dprMq = mq; +} +_initContextLossRecovery() { +this._listen(this.canvas, "webglcontextlost", (e) => { +e.preventDefault(); +if (this._destroyed) return; +const governedRelease = this.canvas.dataset.fcCtx === "released"; +if (this._glLost && !governedRelease) return; +this._glLost = true; +if (!governedRelease) this.canvas.dataset.fcCtx = "lost"; +this._contextLossCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "lost"; +this.seq += 1; +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); +this._wheelZoomRaf = null; +this._pendingWheelZoom = null; +this._cancelViewAnimation(); +clearTimeout(this._viewTimer); +this._viewTimer = null; +clearTimeout(this._rebinTimer); +this._rebinTimer = null; +this._viewRequestBurstStart = null; +this._dispatchChartEvent("context_lost", { +loss_count: this._contextLossCount, +}); +}); +this._listen(this.canvas, "webglcontextrestored", () => { +if (this._destroyed || this._contextRecoveryError) return; +this._lutCache.clear(); +this.pickFbo = null; +this.pickTex = null; +try { +this._initGl(this._payload); +} catch (err) { +this._glLost = true; +this._contextRecoveryError = err; +this.root.dataset.fcContextState = "failed"; +try { this._destroyGlResources(); } catch (_cleanupErr) {} +this.gl = null; +this._dispatchChartEvent("context_restore_failed", { +loss_count: this._contextLossCount, +message: err instanceof Error ? err.message : String(err), +}); +this.root.textContent = "xy: WebGL2 context could not be restored."; +return; +} +this._glLost = false; +this._contextRestoreCount += 1; +this._contextRecoveryError = null; +this.root.dataset.fcContextState = "ready"; +this._scheduleViewRequest(this.view, { delay: 0 }); +this.draw(); +this._dispatchChartEvent("context_restored", { +loss_count: this._contextLossCount, +restore_count: this._contextRestoreCount, +}); +}); +} +_releaseContext() { +if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; +const ext = this.gl.getExtension("WEBGL_lose_context"); +if (!ext) return false; +this._ctxReleasedExt = ext; +this._ctxReleases += 1; +this._glLost = true; +this.canvas.dataset.fcCtx = "released"; +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +ext.loseContext(); +return true; +} +_recoverContext() { +if (this._destroyed || !this._glLost) return; +this._ctxRecoveries += 1; +if (this._ctxReleasedExt) { +const ext = this._ctxReleasedExt; +this._ctxReleasedExt = null; +try { +FC_CONTEXT_GOVERNOR.reserve(this); +ext.restoreContext(); +return; +} catch (_err) { +FC_CONTEXT_GOVERNOR.cancel(this); +} +} +this._rebuildEvictedContext(); +} +_rebuildEvictedContext() { +const fresh = this.canvas.cloneNode(false); +for (const record of this._listeners) { +if (record.target === this.canvas) { +this.canvas.removeEventListener(record.type, record.handler, record.options); +fresh.addEventListener(record.type, record.handler, record.options); +record.target = fresh; +} +} +this.canvas.replaceWith(fresh); +this.canvas = fresh; +this._glLost = false; +this._lutCache.clear(); +this.pickFbo = null; +this.pickTex = null; +try { +this._initGl(this._payload); +} catch (_err) { +this._glLost = true; +this.canvas.dataset.fcCtx = "lost"; +return; +} +this._scheduleViewRequest(this.view, { delay: 0 }); +this.draw(); +} +_armContextVisibilityWatch() { +if (typeof IntersectionObserver === "undefined") { +this._ctxVisible = true; +return; +} +this._ctxIo = new IntersectionObserver( +(entries) => { +const entry = entries[entries.length - 1]; +this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; +if (this._ctxVisible) { +this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; +if (this._glLost && !this._destroyed) this._recoverContext(); +} +}, +{ rootMargin: "25% 0px 25% 0px" }, +); +this._ctxIo.observe(this.root); +} +_resize(cssW, cssH) { +const w = this.fluid && cssW ? Math.max(120, Math.round(cssW)) : this.size.w; +const h = this.fluidH && cssH ? Math.max(120, Math.round(cssH)) : this.size.h; +const dpr = window.devicePixelRatio || 1; +if (w === this.size.w && h === this.size.h && dpr === this.dpr) return; +this.dpr = dpr; +this.size.w = w; +this.size.h = h; +this._layout(); +const p = this.plot; +this.canvas.style.width = p.w + "px"; +this.canvas.style.height = p.h + "px"; +this.canvas.width = p.w * this.dpr; +this.canvas.height = p.h * this.dpr; +this.chrome.style.width = this.size.w + "px"; +this.chrome.style.height = this.size.h + "px"; +this.chrome.width = this.size.w * this.dpr; +this.chrome.height = this.size.h * this.dpr; +if (this._legend && this._slotStyleValue("legend", "max-height") == null) { +this._legend.style.maxHeight = p.h - 12 + "px"; +} +this._positionReductionBadges(); +this._positionColorbar(); +this._pickDirty = true; +this.draw(); +this._scheduleViewRequest(); +} +_buildDom(el) { +const s = this.spec; +const root = document.createElement("div"); +root.className = "xy"; +root.style.cssText = +`position:relative;width:${this.fluid ? "100%" : this.size.w + "px"};` + +`height:${this.fluidH ? "100%" : this.size.h + "px"};` + +(this.fluidH ? "min-height:120px;" : "") + +"font:12px system-ui,sans-serif;user-select:none;"; +this._applySlot(root, "root"); +el.appendChild(root); +this.root = root; +ensureChromeStylesheet(root); +if (s.title) { +const t = document.createElement("div"); +t.textContent = s.title; +t.style.cssText = "position:absolute;top:6px;left:0;right:0;"; +this._applySlot(t, "title"); +root.appendChild(t); +} +this.chrome = document.createElement("canvas"); +this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;"; +this._applySlot(this.chrome, "chrome"); +root.appendChild(this.chrome); +this.canvas = document.createElement("canvas"); +this.canvas.style.cssText = +`position:absolute;left:${this.plot.x}px;top:${this.plot.y}px;` + +`width:${this.plot.w}px;height:${this.plot.h}px;touch-action:none;`; +this._applySlot(this.canvas, "canvas"); +root.appendChild(this.canvas); +this.labels = document.createElement("div"); +this.labels.style.cssText = "position:absolute;inset:0;pointer-events:none;"; +this._applySlot(this.labels, "labels"); +root.appendChild(this.labels); +this.tooltip = document.createElement("div"); +this.tooltip.style.cssText = +"position:absolute;display:none;pointer-events:none;z-index:5;white-space:nowrap;"; +this._applySlot(this.tooltip, "tooltip"); +root.appendChild(this.tooltip); +this._buildLegend(root); +this._buildColorbar(root); +this._buildReductionBadges(root); +} +_compactInt(value) { +const n = Number(value); +if (!Number.isFinite(n)) return "0"; +return Math.round(n).toLocaleString(); +} +_positionReductionBadges() { +if (!this._badges) return; +const rightInset = this.size.w - (this.plot.x + this.plot.w); +const bottomInset = this.size.h - (this.plot.y + this.plot.h); +this._badges.style.right = `${rightInset + 6}px`; +this._badges.style.bottom = `${bottomInset + 6}px`; +} +_reductionBadgeItems() { +const items = []; +const traces = this.gpuTraces && this.gpuTraces.length +? this.gpuTraces +: (this.spec.traces || []); +for (const entry of traces) { +const t = entry.trace || entry; +if (t.tier !== "density" || !t.density) continue; +const sample = entry.sampleOverlay && entry.sampleOverlay.sample +? entry.sampleOverlay.sample +: t.density.sample; +if (sample && Number(sample.n) > 0) { +items.push(`sampled ${this._compactInt(sample.n)} of ${this._compactInt(sample.visible)}`); +} +if (entry._sampleRebinned) items.push("zoom re-binned from sample"); +if (t.density.channels_dropped) items.push("aggregated channels"); +} +return items; +} +_refreshReductionBadges() { +if (!this._badges) return; +const items = this._reductionBadgeItems(); +this._badges.textContent = ""; +this._badges.hidden = items.length === 0; +for (const item of items) { +const badge = document.createElement("div"); +badge.textContent = item; +this._applySlot(badge, "badge_item"); +this._badges.appendChild(badge); +} +this._positionReductionBadges(); +} +_buildReductionBadges(root) { +const items = this._reductionBadgeItems(); +const hasDensityTrace = (this.spec.traces || []).some((t) => t.tier === "density"); +if (!items.length && !hasDensityTrace) return; +const box = document.createElement("div"); +box.style.cssText = +"position:absolute;display:flex;flex-direction:column;align-items:flex-end;" + +"pointer-events:none;z-index:4;"; +this._applySlot(box, "badge"); +root.appendChild(box); +this._badges = box; +this._refreshReductionBadges(); +} +_buildLegend(root) { +const s = this.spec; +if (s.show_legend === false) return; +const items = []; +for (const t of s.traces) { +if (t.tier === "density") { +items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" }); +} else if (t.color && t.color.mode === "categorical") { +t.color.categories.forEach((cat, i) => +items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} })); +} else if (t.color && t.color.mode === "continuous") { +items.push({ swatch: "gradient", cmap: t.color.colormap, name: t.name || "value" }); +} else if (t.name) { +const c = (t.color && t.color.color) || (t.style && t.style.color); +items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} }); +} +} +if (!items.length) return; +const lg = document.createElement("div"); +const options = s.legend || {}; +const loc = options.loc || "upper right"; +const ncols = Math.max(1, Number(options.ncols) || 1); +const rightInset = this.size.w - (this.plot.x + this.plot.w); +const horizontal = ncols > 1; +const xPos = loc.includes("left") +? `left:${this.plot.x + 6}px;` +: loc.includes("center") +? `left:${this.plot.x + this.plot.w / 2}px;transform:translateX(-50%);` +: `right:${rightInset + 6}px;`; +const yPos = loc.includes("lower") +? `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;` +: loc === "center" || loc.includes("center left") || loc.includes("center right") +? `top:${this.plot.y + this.plot.h / 2}px;transform:${loc.includes("center") && !loc.includes("left") && !loc.includes("right") ? "translate(-50%,-50%)" : "translateY(-50%)"};` +: `top:${this.plot.y + 6}px;`; +lg.style.cssText = `position:absolute;${xPos}${yPos}` + +`display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + +"overflow:auto;" + `max-height:${this.plot.h - 12}px;`; +this._applySlot(lg, "legend"); +if (options.title) { +const title = document.createElement("div"); +title.textContent = String(options.title); +title.style.fontWeight = "600"; +title.style.gridColumn = `1 / span ${horizontal ? ncols : 1}`; +lg.appendChild(title); +} +for (const it of items) { +const row = document.createElement("div"); +this._applySlot(row, "legend_item"); +const sw = document.createElement("span"); +sw.style.display = "inline-block"; +sw.style.verticalAlign = "-1px"; +let bg = it.swatch; +if (it.swatch === "gradient") { +const stops = colormapStops(it.cmap); +bg = `linear-gradient(90deg,${stops.map((c) => `rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`; +sw.style.background = bg; +} else if (it.symbol) { +const ns = "http://www.w3.org/2000/svg"; +const svg = document.createElementNS(ns, "svg"); +svg.setAttribute("viewBox", "0 0 18 14"); +svg.setAttribute("width", "18"); +svg.setAttribute("height", "14"); +const path = document.createElementNS(ns, "path"); +const paths = { +square: "M4.5 2.5h9v9h-9z", diamond: "M9 2l5 5-5 5-5-5z", +thin_diamond: "M9 2l3 5-3 5-3-5z", +triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z", +triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z", +plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8", +cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z", +x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z", +pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z", +hexagon: "M9 2L13.3 4.5v5L9 12l-4.3-2.5v-5z", +star: "M9 2l1.5 3.1 3.5.5-2.5 2.5.6 3.5L9 10l-3.1 1.6.6-3.5L4 5.6l3.5-.5z" +}; +const color = safeCssPaint(this.root, bg); +if (it.symbol === "circle" || it.symbol === "point" || it.symbol === "pixel") { +if (it.symbol === "pixel") path.setAttribute("d", "M8.5 6.5h1v1h-1z"); +else path.setAttribute("d", `M9 ${it.symbol === "point" ? 4.75 : 2.5}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 ${it.symbol === "point" ? 4.5 : 9}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 -${it.symbol === "point" ? 4.5 : 9}`); +} else path.setAttribute("d", paths[it.symbol] || paths.square); +path.setAttribute("fill", it.symbol.endsWith("_line") ? "none" : color); +path.setAttribute("stroke", color); +path.setAttribute("stroke-width", String(it.style?.stroke_width || 1)); +svg.appendChild(path); +sw.appendChild(svg); +sw.style.width = "18px"; +sw.style.height = "14px"; +} else { +sw.style.background = safeCssPaint(this.root, bg); +} +this._applySlot(sw, "legend_swatch"); +row.appendChild(sw); +row.appendChild(document.createTextNode(it.name)); +lg.appendChild(row); +} +root.appendChild(lg); +this._legend = lg; +} +_buildColorbar(root) { +const cb = this.spec.colorbar; +if (!cb) return; +const box = document.createElement("div"); +const horizontal = cb.orientation === "horizontal"; +box.style.cssText = "position:absolute;pointer-events:none;z-index:4;"; +this._applySlot(box, "colorbar"); +const bar = document.createElement("div"); +const levels = Math.max(0, Number(cb.levels) || 0); +let gradient; +if (levels > 0) { +const lut = buildLutData(cb.colormap || "viridis"); +const bands = []; +for (let index = 0; index < levels; index++) { +const sample = Math.min(255, Math.round(255 * (index + 0.5) / levels)); +const color = `rgb(${lut[sample * 4]},${lut[sample * 4 + 1]},${lut[sample * 4 + 2]})`; +bands.push(`${color} ${100 * index / levels}% ${100 * (index + 1) / levels}%`); +} +gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${bands.join(",")})`; +} else { +const stops = colormapStops(cb.colormap || "viridis"); +gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${stops.map((c) => +`rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`; +} +bar.style.cssText = horizontal +? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;` +: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`; +bar.style.setProperty("--xy-colorbar-gradient", gradient); +this._applySlot(bar, "colorbar_bar"); +box.appendChild(bar); +const domain = cb.domain || [0, 1]; +const lo = Number(domain[0]), hi = Number(domain[1]); +const span = hi - lo || 1; +const tickResult = linearTicks(lo, hi, 8); +const tickValues = Array.isArray(cb.ticks) ? cb.ticks : tickResult.ticks; +const tickStep = tickResult.step; +for (const raw of tickValues) { +const value = Number(raw); +if (!Number.isFinite(value) || value < Math.min(lo, hi) || value > Math.max(lo, hi)) continue; +const tick = document.createElement("span"); +tick.textContent = fmtLinear(value, tickStep); +const fraction = (value - lo) / span; +tick.style.cssText = horizontal +? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;` +: `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`; +this._applySlot(tick, "colorbar_tick"); +box.appendChild(tick); +} +if (cb.label) { +const label = document.createElement("span"); +label.textContent = String(cb.label); +label.style.cssText = horizontal +? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;` +: `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`; +this._applySlot(label, "colorbar_title"); +box.appendChild(label); +} +box.title = `${cb.label ? cb.label + ": " : ""}${domain[0]} – ${domain[1]}`; +root.appendChild(box); +this._colorbar = box; +this._colorbarHorizontal = horizontal; +this._positionColorbar(); +} +_positionColorbar() { +if (!this._colorbar) return; +const horizontal = this._colorbarHorizontal; +this._colorbar.style.left = (horizontal ? this.plot.x : this.plot.x + this.plot.w + COLORBAR_GAP) + "px"; +this._colorbar.style.top = (horizontal ? this.plot.y + this.plot.h + 8 : this.plot.y) + "px"; +this._colorbar.style.width = (horizontal ? this.plot.w : 66) + "px"; +this._colorbar.style.height = (horizontal ? 50 : Math.max(24, this.plot.h)) + "px"; +} +_initGl(buffer) { +const dpr = window.devicePixelRatio || 1; +this.dpr = dpr; +this.canvas.width = this.plot.w * dpr; +this.canvas.height = this.plot.h * dpr; +this.chrome.width = this.size.w * dpr; +this.chrome.height = this.size.h * dpr; +this.chrome.style.width = this.size.w + "px"; +this.chrome.style.height = this.size.h + "px"; +FC_CONTEXT_GOVERNOR.reserve(this); +const gl = this.canvas.getContext("webgl2", { +antialias: false, premultipliedAlpha: true, alpha: true, +}); +if (!gl) { +FC_CONTEXT_GOVERNOR.cancel(this); +this.root.textContent = "xy: WebGL2 unavailable in this browser."; +throw new Error("webgl2 unavailable"); +} +this.gl = gl; +FC_CONTEXT_GOVERNOR.acquired(this); +this.canvas.dataset.fcCtx = "live"; +gl.enable(gl.BLEND); +gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); +this._progCache = new Map(); +this._glPrograms = this._progCache; +this.quad = gl.createBuffer(); +this.quad._fcId = ++this._bufSeq; +gl.bindBuffer(gl.ARRAY_BUFFER, this.quad); +gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); +this.quadVao = gl.createVertexArray(); +gl.bindVertexArray(this.quadVao); +gl.enableVertexAttribArray(ATTR_SLOTS.a_corner); +gl.vertexAttribPointer(ATTR_SLOTS.a_corner, 2, gl.FLOAT, false, 0, 0); +gl.vertexAttribDivisor(ATTR_SLOTS.a_corner, 0); +gl.bindVertexArray(null); +this.gpuTraces = this.spec.traces.map((t) => this._buildTrace(buffer, t)); +this._pickable = this.gpuTraces.some((g) => markOf(g.trace.kind).pointPick && g.tier !== "density"); +if (this._pickable) this._initPickTarget(); +} +_prog(key, vs, fs) { +let p = this._progCache.get(key); +if (!p) { +p = makeProgram(this.gl, vs, fs); +this._progCache.set(key, p); +} +return p; +} +get pointProg() { return this._prog("point", POINT_VS, POINT_FS); } +get pointSimpleProg() { return this._prog("point-simple", POINT_SIMPLE_VS, POINT_SIMPLE_FS); } +get lineProg() { return this._prog("line", LINE_VS, LINE_FS); } +get segmentProg() { return this._prog("segment", SEGMENT_VS, SEGMENT_FS); } +get meshProg() { return this._prog("mesh", MESH_VS, MESH_FS); } +get areaProg() { return this._prog("area", AREA_VS, AREA_FS); } +get rectProg() { return this._prog("rect", RECT_VS, RECT_FS); } +get barProg() { return this._prog("bar", BAR_VS, RECT_FS); } +get pickProg() { return this._prog("pick", PICK_VS, PICK_FS); } +get densityProg() { return this._prog("density", GRID_VS, DENSITY_FS); } +get heatmapProg() { return this._prog("heatmap", GRID_VS, HEATMAP_FS); } +_lut(name) { +if (this._lutCache.has(name)) return this._lutCache.get(name); +const gl = this.gl; +const tex = gl.createTexture(); +gl.bindTexture(gl.TEXTURE_2D, tex); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, buildLutData(name)); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +this._lutCache.set(name, tex); +return tex; +} +_paletteLut(palette) { +const key = "pal:" + palette.join(","); +if (this._lutCache.has(key)) return this._lutCache.get(key); +const gl = this.gl; +const data = new Uint8Array(256 * 4); +for (let i = 0; i < 256; i++) { +const c = hexColor(palette[i % palette.length]); +data[i * 4] = c[0] * 255; +data[i * 4 + 1] = c[1] * 255; +data[i * 4 + 2] = c[2] * 255; +data[i * 4 + 3] = 255; +} +const tex = gl.createTexture(); +gl.bindTexture(gl.TEXTURE_2D, tex); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +this._lutCache.set(key, tex); +return tex; +} +_buildTrace(buffer, t) { +const gl = this.gl; +const g = { +trace: t, +tier: t.tier, +color: [0.3, 0.47, 0.66, 1], +xAxis: typeof t.x_axis === "string" ? t.x_axis : "x", +yAxis: typeof t.y_axis === "string" ? t.y_axis : "y", +}; +if (t.tier === "density") { +const d = t.density; +const meta = this.spec.columns[d.buf]; +const raw = this._columnView(buffer, meta); +const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw; +g.densityNormMax = d.max; +g.density = { +w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap, +color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null, +xRange: d.x_range, yRange: d.y_range, +grid: lodCopyGrid(grid), +tex: this._uploadGrid(grid, d.w, d.h, d.max), +lut: this._lut(d.colormap), +}; +g.sampleOverlay = this._buildDensitySample(t, d.sample, buffer); +g._shownDensity = g.density; +lodRememberDensity(this, g, g.density); +return g; +} +markOf(t.kind).build(this, g, t, buffer); +return g; +} +_buildXY(g, t, buffer) { +const x = this._columnView(buffer, this.spec.columns[t.x]); +const y = this._columnView(buffer, this.spec.columns[t.y]); +g.xMeta = { ...this.spec.columns[t.x] }; +g.yMeta = { ...this.spec.columns[t.y] }; +g.n = Math.min(x.length, y.length); +g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; +g.xBuf = this._upload(x); +g.yBuf = this._upload(y); +} +_buildScatterMark(g, t, buffer) { +this._buildXY(g, t, buffer); +g.colorMode = 0; +g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); +if (t.color && t.color.mode === "continuous") { +g.colorMode = 1; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._lut(t.color.colormap); +} else if (t.color && t.color.mode === "categorical") { +g.colorMode = 2; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._paletteLut(t.color.palette); +} +g.sizeMode = 0; +g.size = (t.size && t.size.size) || 4.0; +g.sizeRange = [2, 18]; +if (t.size && t.size.mode === "continuous") { +g.sizeMode = 1; +g.sBuf = this._upload(this._columnView(buffer, this.spec.columns[t.size.buf])); +g.sizeRange = t.size.range_px; +} +this._pointMarkStyle(g, t); +} +_pointMarkStyle(g, t) { +const s = t.style || {}; +g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; +g.pointStrokeWidth = Number(s.stroke_width) || 0; +g.pointStrokeFace = !s.stroke; +g.pointStroke = s.stroke +? parseColor(this.root, s.stroke, [g.color[0], g.color[1], g.color[2], 1]) +: null; +} +_sampleTraceSpec(parentTrace, sample) { +return { +id: parentTrace.id, +kind: "scatter", +name: parentTrace.name, +style: sample.style || parentTrace.style || {}, +tier: "sampled", +x: sample.x && sample.x.col, +y: sample.y && sample.y.col, +x_axis: parentTrace.x_axis, +y_axis: parentTrace.y_axis, +color: sample.color, +size: sample.size, +}; +} +_buildDensitySample(parentTrace, sample, buffer) { +if (!sample || !sample.x || !sample.y || sample.x.col === undefined || sample.y.col === undefined) { +return null; +} +const trace = this._sampleTraceSpec(parentTrace, sample); +const g = { +trace, +tier: "sampled", +xAxis: typeof parentTrace.x_axis === "string" ? parentTrace.x_axis : "x", +yAxis: typeof parentTrace.y_axis === "string" ? parentTrace.y_axis : "y", +}; +this._buildScatterMark(g, trace, buffer); +g.win = { +x0: sample.x_range[0], x1: sample.x_range[1], +y0: sample.y_range[0], y1: sample.y_range[1], +}; +g.sample = { n: sample.n, visible: sample.visible }; +return g; +} +_destroyDensitySample(g) { +const s = g && g.sampleOverlay; +if (!s || !this.gl) return; +for (const b of [s.xBuf, s.yBuf, s.cBuf, s.sBuf, s.selBuf, s.dBuf]) { +if (b) this.gl.deleteBuffer(b); +} +g.sampleOverlay = null; +} +_applyDensitySample(g, sample, buffers) { +this._destroyDensitySample(g); +if (!sample || !sample.x || !sample.y || sample.x.buf === undefined || sample.y.buf === undefined) { +this._refreshReductionBadges(); +return; +} +const gl = this.gl; +const trace = { +id: g.trace.id, +kind: "scatter", +name: g.trace.name, +style: sample.style || g.trace.style || {}, +tier: "sampled", +x_axis: g.trace.x_axis, +y_axis: g.trace.y_axis, +color: sample.color, +size: sample.size, +}; +const s = { +trace, +tier: "sampled", +xAxis: g.xAxis, +yAxis: g.yAxis, +xBuf: gl.createBuffer(), +yBuf: gl.createBuffer(), +xMeta: { offset: sample.x.offset, scale: sample.x.scale }, +yMeta: { offset: sample.y.offset, scale: sample.y.scale }, +n: Math.min(sample.x.len, sample.y.len), +win: { +x0: sample.x_range[0], x1: sample.x_range[1], +y0: sample.y_range[0], y1: sample.y_range[1], +}, +sample: { n: sample.n, visible: sample.visible }, +selActive: false, +colorMode: 0, +color: parseColor(this.root, sample.color && sample.color.color, [0.3, 0.47, 0.66, 1]), +sizeMode: 0, +size: (sample.size && sample.size.size) || 4.0, +sizeRange: [2, 18], +}; +gl.bindBuffer(gl.ARRAY_BUFFER, s.xBuf); +gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.x.buf]), gl.STATIC_DRAW); +gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); +gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); +if (sample.color && sample.color.buf !== undefined) { +s.colorMode = sample.color.mode === "continuous" ? 1 : 2; +s.cBuf = gl.createBuffer(); +const colorValues = sample.color.dtype === "u8" +? this._asU8(buffers[sample.color.buf]) +: this._asF32(buffers[sample.color.buf]); +s.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, s.cBuf); +gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +s.lut = sample.color.mode === "continuous" +? this._lut(sample.color.colormap) +: this._paletteLut(sample.color.palette); +} +if (sample.size && sample.size.mode === "continuous") { +s.sizeMode = 1; +s.sBuf = gl.createBuffer(); +gl.bindBuffer(gl.ARRAY_BUFFER, s.sBuf); +gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.size.buf]), gl.STATIC_DRAW); +s.sizeRange = sample.size.range_px; +} +g.sampleOverlay = s; +this._refreshReductionBadges(); +} +_drawDensitySample(g, x0, x1, y0, y1, opacityScale = 1) { +const s = g && g.sampleOverlay; +if (!s || !s.n || !this._viewInside(s.win)) return; +this._drawPoints( +s, +this._map(s.xMeta, x0, x1, s.xAxis), +this._map(s.yMeta, y0, y1, s.yAxis), +opacityScale +); +} +_resolveMarkFill(style, markColor) { +const fill = style && style.fill; +if (!fill || !Array.isArray(fill.stops) || fill.stops.length < 2) return null; +const mode = fill.space === "plot" ? 2 : 1; +const dir = { down: 0, up: 1, left: 2, right: 3 }[fill.dir] ?? 0; +const count = Math.min(fill.stops.length, 8); +const pos = new Float32Array(8); +const colors = new Float32Array(32); +for (let i = 0; i < count; i++) { +const stop = fill.stops[i] || []; +pos[i] = Math.min(Math.max(Number(stop[0]) || 0, 0), 1); +const expr = String(stop[1] || "").trim(); +const c = expr.toLowerCase() === "currentcolor" +? markColor +: parseColor(this.root, expr, markColor); +colors[i * 4] = c[0] * c[3]; +colors[i * 4 + 1] = c[1] * c[3]; +colors[i * 4 + 2] = c[2] * c[3]; +colors[i * 4 + 3] = c[3]; +} +return { mode, dir, count, pos, colors }; +} +_setGradientUniforms(prog, grad) { +const gl = this.gl; +const u = (n) => uniformOf(gl, prog, n); +if (!grad) { +gl.uniform1i(u("u_gradMode"), 0); +return; +} +gl.uniform1i(u("u_gradMode"), grad.mode); +gl.uniform1i(u("u_gradDir"), grad.dir); +gl.uniform1i(u("u_gradCount"), grad.count); +gl.uniform1fv(u("u_gradPos"), grad.pos); +gl.uniform4fv(u("u_gradColor"), grad.colors); +} +_fillOpacity(style, fallback = 1) { +return Number(style.opacity ?? fallback) * Number(style.fill_opacity ?? 1); +} +_strokeOpacity(style, fallback = 1) { +return Number(style.opacity ?? fallback) * Number(style.stroke_opacity ?? 1); +} +_setRectStyleUniforms(prog, g) { +const gl = this.gl; +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); +const cr = g.cornerRadius || [0, 0]; +gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr); +gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); +const sc = g.strokeColor || [0, 0, 0, 0]; +const sa = sc[3] * this._strokeOpacity(g.trace.style || {}); +gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa); +this._setGradientUniforms(prog, g.grad); +} +_rectMarkStyleGpu(g, t) { +const s = t.style || {}; +const cr = s.corner_radius; +g.cornerRadius = Array.isArray(cr) +? [Number(cr[0]) || 0, Number(cr[1]) || 0] +: [Number(cr) || 0, Number(cr) || 0]; +g.strokeWidth = Number(s.stroke_width) || 0; +const opaque = [g.color[0], g.color[1], g.color[2], 1]; +g.strokeColor = s.stroke ? parseColor(this.root, s.stroke, opaque) : opaque; +g.grad = this._resolveMarkFill(s, g.color); +} +_smoothArrays(t, x, y, base, n) { +if (!t.style || t.style.curve !== "smooth") return null; +return fcSmoothResample(x, y, base || null, n, 32768); +} +_stepArrays(t, x, y, n) { +const where = t.style && t.style.step; +if (!where || n < 2) return null; +const perGap = where === "mid" ? 3 : 2; +const m = 1 + (n - 1) * perGap; +const sx = new Float32Array(m); +const sy = new Float32Array(m); +sx[0] = x[0]; +sy[0] = y[0]; +let j = 1; +for (let i = 1; i < n; i++) { +if (where === "pre") { +sx[j] = x[i - 1]; sy[j] = y[i]; j++; +sx[j] = x[i]; sy[j] = y[i]; j++; +} else if (where === "mid") { +const mid = (x[i - 1] + x[i]) * 0.5; +sx[j] = mid; sy[j] = y[i - 1]; j++; +sx[j] = mid; sy[j] = y[i]; j++; +sx[j] = x[i]; sy[j] = y[i]; j++; +} else { +sx[j] = x[i]; sy[j] = y[i - 1]; j++; +sx[j] = x[i]; sy[j] = y[i]; j++; +} +} +return { x: sx, y: sy, n: m }; +} +_buildLineMark(g, t, buffer) { +const x = this._columnView(buffer, this.spec.columns[t.x]); +const y = this._columnView(buffer, this.spec.columns[t.y]); +g.xMeta = { ...this.spec.columns[t.x] }; +g.yMeta = { ...this.spec.columns[t.y] }; +g.n = Math.min(x.length, y.length); +g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; +const sm = this._smoothArrays(t, x, y, null, g.n); +const src = sm || { x, y, n: g.n }; +const st = this._stepArrays(t, src.x, src.y, src.n); +const drawX = st ? st.x : src.x; +const drawY = st ? st.y : src.y; +g.xBuf = this._upload(drawX); +g.yBuf = this._upload(drawY); +g.n = st ? st.n : src.n; +g._dashX = drawX; +g._dashY = drawY; +g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); +} +_buildSegmentMark(g, t, buffer) { +const x0 = this._columnView(buffer, this.spec.columns[t.x0]); +const x1 = this._columnView(buffer, this.spec.columns[t.x1]); +const y0 = this._columnView(buffer, this.spec.columns[t.y0]); +const y1 = this._columnView(buffer, this.spec.columns[t.y1]); +g.x0Meta = { ...this.spec.columns[t.x0] }; +g.x1Meta = { ...this.spec.columns[t.x1] }; +g.y0Meta = { ...this.spec.columns[t.y0] }; +g.y1Meta = { ...this.spec.columns[t.y1] }; +g.n = Math.min(x0.length, x1.length, y0.length, y1.length); +g.x0Buf = this._upload(x0); +g.x1Buf = this._upload(x1); +g.y0Buf = this._upload(y0); +g.y1Buf = this._upload(y1); +g._segmentCpu = { x0, x1, y0, y1 }; +g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && t.color.mode === "continuous") { +g.colorMode = 1; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._lut(t.color.colormap); +} else if (t.color && t.color.mode === "categorical") { +g.colorMode = 2; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._paletteLut(t.color.palette); +} +g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; +} +_buildMeshMark(g, t, buffer) { +for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { +const values = this._columnView(buffer, this.spec.columns[t[name]]); +g[name + "Meta"] = { ...this.spec.columns[t[name]] }; +g[name + "Buf"] = this._upload(values); +g.n = g.n === undefined ? values.length : Math.min(g.n, values.length); +} +g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && t.color.mode === "continuous") { +g.colorMode = 1; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._lut(t.color.colormap); +} else if (t.color && t.color.mode === "categorical") { +g.colorMode = 2; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._paletteLut(t.color.palette); +} +const style = t.style || {}; +g.meshStrokeWidth = Number(style.stroke_width) || 0; +g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); +} +_buildAreaMark(g, t, buffer) { +const x = this._columnView(buffer, this.spec.columns[t.x]); +const y = this._columnView(buffer, this.spec.columns[t.y]); +const base = this._columnView(buffer, this.spec.columns[t.base]); +g.xMeta = { ...this.spec.columns[t.x] }; +g.yMeta = { ...this.spec.columns[t.y] }; +g.baseMeta = { ...this.spec.columns[t.base] }; +g.n = Math.min(x.length, y.length, base.length); +g._cpu = { x, y, base, xMeta: g.xMeta, yMeta: g.yMeta }; +const sm = this._smoothArrays(t, x, y, base, g.n); +g.xBuf = this._upload(sm ? sm.x : x); +g.yBuf = this._upload(sm ? sm.y : y); +g.baseBuf = this._upload(sm ? sm.extra : base); +if (sm) g.n = sm.n; +g._dashX = sm ? sm.x : x; +g._dashY = sm ? sm.y : y; +g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); +g.lineColor = parseColor(this.root, t.style && (t.style.line_color || t.style.color), g.color); +g.grad = this._resolveMarkFill(t.style, g.color); +} +_buildRectMark(g, t, buffer) { +const x0 = this._columnView(buffer, this.spec.columns[t.x0]); +const x1 = this._columnView(buffer, this.spec.columns[t.x1]); +const y0 = this._columnView(buffer, this.spec.columns[t.y0]); +const y1 = this._columnView(buffer, this.spec.columns[t.y1]); +g.x0Meta = { ...this.spec.columns[t.x0] }; +g.x1Meta = { ...this.spec.columns[t.x1] }; +g.y0Meta = { ...this.spec.columns[t.y0] }; +g.y1Meta = { ...this.spec.columns[t.y1] }; +g.n = Math.min(x0.length, x1.length, y0.length, y1.length); +g._cpuRect = { +x0, x1, y0, y1, +x0Meta: g.x0Meta, x1Meta: g.x1Meta, y0Meta: g.y0Meta, y1Meta: g.y1Meta, +}; +g.x0Buf = this._upload(x0); +g.x1Buf = this._upload(x1); +g.y0Buf = this._upload(y0); +g.y1Buf = this._upload(y1); +g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && t.color.mode === "continuous") { +g.colorMode = 1; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._lut(t.color.colormap); +} else if (t.color && t.color.mode === "categorical") { +g.colorMode = 2; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._paletteLut(t.color.palette); +} +this._rectMarkStyleGpu(g, t); +} +_buildBarMark(g, t, buffer) { +const b = t.bar; +if (!b) return this._buildRectMark(g, t, buffer); +const pos = this._columnView(buffer, this.spec.columns[b.pos]); +const v1 = this._columnView(buffer, this.spec.columns[b.value1]); +g.posMeta = { ...this.spec.columns[b.pos] }; +g.value1Meta = { ...this.spec.columns[b.value1] }; +g.n = Math.min(pos.length, v1.length); +g.posBuf = this._upload(pos); +g.value1Buf = this._upload(v1); +g.orientation = b.orientation === "horizontal" ? 1 : 0; +g.value0Const = b.value0_const ?? 0; +g.value0Mode = b.value0 === undefined ? 0 : 1; +g.width = b.width; +if (g.value0Mode === 1) { +const v0 = this._columnView(buffer, this.spec.columns[b.value0]); +g.value0Meta = { ...this.spec.columns[b.value0] }; +g.n = Math.min(g.n, v0.length); +g._cpuValue0 = v0; +g.value0Buf = this._upload(v0); +} +g._cpu = g.orientation === 1 +? { x: v1, y: pos, xMeta: g.value1Meta, yMeta: g.posMeta, value0: g._cpuValue0 } +: { x: pos, y: v1, xMeta: g.posMeta, yMeta: g.value1Meta, value0: g._cpuValue0 }; +g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && t.color.mode === "continuous") { +g.colorMode = 1; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._lut(t.color.colormap); +} else if (t.color && t.color.mode === "categorical") { +g.colorMode = 2; +g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); +g.lut = this._paletteLut(t.color.palette); +} +this._rectMarkStyleGpu(g, t); +} +_buildHeatmapMark(g, t, buffer) { +const h = t.heatmap; +const truecolor = Array.isArray(h.rgba_bufs); +const grid = truecolor +? h.rgba_bufs.map((index) => this._columnView(buffer, this.spec.columns[index])) +: this._columnView(buffer, this.spec.columns[h.buf]); +g.heatmap = { +w: h.w, +h: h.h, +xRange: h.x_range, +yRange: h.y_range, +colormap: h.colormap, +truecolor, +tex: truecolor ? this._uploadRgbaGrid(grid, h.w, h.h) : this._uploadHeatmapGrid(grid, h.w, h.h), +lut: truecolor ? null : this._lut(h.colormap), +}; +if (!truecolor) g._cpuHeatmap = { grid }; +} +_uploadRgbaGrid(channels, w, h) { +const gl = this.gl; +const tex = gl.createTexture(); +const data = new Uint8Array(w * h * 4); +for (let index = 0; index < w * h; index++) { +for (let channel = 0; channel < 4; channel++) { +data[index * 4 + channel] = Math.round(255 * Math.max(0, Math.min(1, channels[channel][index]))); +} +} +gl.bindTexture(gl.TEXTURE_2D, tex); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +return tex; +} +_uploadGrid(f32, w, h, maxVal) { +const gl = this.gl; +const tex = gl.createTexture(); +lodWriteGridTexture(gl, tex, f32, w, h, maxVal); +return tex; +} +_uploadHeatmapGrid(f32, w, h) { +const gl = this.gl; +const tex = gl.createTexture(); +const data = new Uint8Array(f32.length); +for (let i = 0; i < f32.length; i++) { +const v = f32[i]; +if (Number.isFinite(v)) { +data[i] = Math.max(1, Math.min(255, Math.round(1 + 254 * Math.max(0, Math.min(1, v))))); +} +} +gl.bindTexture(gl.TEXTURE_2D, tex); +const align = gl.getParameter(gl.UNPACK_ALIGNMENT); +gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); +gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +return tex; +} +_columnView(buffer, meta) { +const split = Array.isArray(buffer); +if (split !== Number.isInteger(meta.buf)) { +throw new Error( +split +? "xy: transport delivered a buffer list but the spec column has no wire-buffer index" +: "xy: spec column carries a wire-buffer index but the transport delivered one blob", +); +} +const span = fcByteSpan(split ? buffer[meta.buf] : buffer, "chart payload"); +const relativeOffset = Number(meta.byte_offset); +const length = Number(meta.len); +if (!Number.isSafeInteger(relativeOffset) || relativeOffset < 0 || +!Number.isSafeInteger(length) || length < 0) { +throw new RangeError("column offset/length must be non-negative safe integers"); +} +const bytesPerElement = meta.dtype === "u8" ? 1 : 4; +const absoluteOffset = span.byteOffset + relativeOffset; +const end = relativeOffset + length * bytesPerElement; +if (end > span.byteLength) throw new RangeError("column extends past chart payload"); +if (absoluteOffset % bytesPerElement !== 0) throw new RangeError("column is misaligned"); +if (meta.dtype === "u8") return new Uint8Array(span.buffer, absoluteOffset, length); +return new Float32Array(span.buffer, absoluteOffset, length); +} +_upload(view) { +const gl = this.gl; +const buf = gl.createBuffer(); +buf._fcId = ++this._bufSeq; +buf._fcType = view instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, buf); +gl.bufferData(gl.ARRAY_BUFFER, view, gl.STATIC_DRAW); +return buf; +} +_bindVao(g, key, parts, setup) { +const gl = this.gl; +if (!g._vaos) g._vaos = new Map(); +const sig = parts.join("|"); +let entry = g._vaos.get(key); +if (!entry || entry.sig !== sig) { +if (entry) gl.deleteVertexArray(entry.vao); +const vao = gl.createVertexArray(); +gl.bindVertexArray(vao); +setup(); +entry = { vao, sig }; +g._vaos.set(key, entry); +} else { +gl.bindVertexArray(entry.vao); +} +} +_deleteVaos(g) { +if (!g || !g._vaos) return; +const gl = this.gl; +if (gl) for (const { vao } of g._vaos.values()) gl.deleteVertexArray(vao); +g._vaos = null; +} +_vaoAttr(slot, buf, byteOffset, divisor, size = 1) { +const gl = this.gl; +gl.bindBuffer(gl.ARRAY_BUFFER, buf); +gl.enableVertexAttribArray(slot); +gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, false, 0, byteOffset); +gl.vertexAttribDivisor(slot, divisor); +} +_initPickTarget() { +const gl = this.gl; +this.pickTex = gl.createTexture(); +this._allocPickTex(); +this.pickFbo = gl.createFramebuffer(); +gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); +gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.pickTex, 0); +gl.bindFramebuffer(gl.FRAMEBUFFER, null); +this._pickDirty = true; +} +_allocPickTex() { +const gl = this.gl; +gl.bindTexture(gl.TEXTURE_2D, this.pickTex); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, this.canvas.width, this.canvas.height, 0, +gl.RGBA, gl.UNSIGNED_BYTE, null); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); +this._pickW = this.canvas.width; +this._pickH = this.canvas.height; +} +_map(meta, lo, hi, axisId = null) { +if (!axisId) { +const mul = 2 / ((hi - lo) * meta.scale); +const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; +return [mul, add]; +} +const axis = this._axis(axisId); +const c0 = this._axisCoord(axis, lo); +const c1 = this._axisCoord(axis, hi); +if (![c0, c1].every(Number.isFinite) || c1 === c0) return [0, -2]; +const mul = 2 / (c1 - c0); +const add = -1 - c0 * mul; +return [mul, add]; +} +_mapConst(value, lo, hi, axisId = null) { +if (!axisId) return ((value - lo) / (hi - lo)) * 2 - 1; +const axis = this._axis(axisId); +const c = this._axisCoord(axis, value); +const c0 = this._axisCoord(axis, lo); +const c1 = this._axisCoord(axis, hi); +if (![c, c0, c1].every(Number.isFinite) || c1 === c0) return -2; +return ((c - c0) / (c1 - c0)) * 2 - 1; +} +_edgePadForValue(value, lo, hi, pixels) { +if (!Number.isFinite(value) || !Number.isFinite(lo) || !Number.isFinite(hi) || hi === lo) return 0; +const span = Math.abs(hi - lo); +const eps = span * 1e-10 + 1e-12; +const px = Math.max(1, pixels || 1); +const padPx = Math.max(2, Math.ceil(this.dpr || 1)); +if (Math.abs(value - lo) <= eps) return -(2 * padPx) / px; +if (Math.abs(value - hi) <= eps) return (2 * padPx) / px; +return 0; +} +_setAxisUniforms(prog, prefix, meta, axisId) { +const gl = this.gl; +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u(`${prefix}meta`), meta && Number.isFinite(meta.offset) ? meta.offset : 0, meta && meta.scale ? meta.scale : 1); +gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); +} +draw(keepPick = false) { +if (this._destroyed || this._glLost || !this.gl) return; +if (this._raf) { +this._rafKeepPick = this._rafKeepPick && keepPick; +return; +} +this._rafKeepPick = keepPick; +this._raf = requestAnimationFrame(() => { +this._raf = null; +if (this._destroyed) return; +this._drawNow(); +}); +} +_drawNow() { +if (this._destroyed || !this.gl || this._glLost) return; +const gl = this.gl; +const { x0, x1, y0, y1 } = this.view; +gl.bindFramebuffer(gl.FRAMEBUFFER, null); +gl.viewport(0, 0, this.canvas.width, this.canvas.height); +const bg = this.theme.bg; +if (bg) gl.clearColor(bg[0] * bg[3], bg[1] * bg[3], bg[2] * bg[3], bg[3]); +else gl.clearColor(0, 0, 0, 0); +gl.clear(gl.COLOR_BUFFER_BIT); +for (const g of this.gpuTraces) { +if (g.tier === "density") { +const [gx0, gx1] = this._axisRange(g.xAxis); +const [gy0, gy1] = this._axisRange(g.yAxis); +lodDrawDensityTier(this, g, gx0, gx1, gy0, gy1); +continue; +} +markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1); +} +this._drawHoverState(); +if (!this._rafKeepPick) this._pickDirty = true; +this._rafKeepPick = false; +this._drawChrome(); +} +_now() { +return performance.now(); +} +_drawPoints(g, xm, ym, opacityScale = 1) { +const simple = +g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && +(g.symbol || 0) === 0 && (g.pointStrokeWidth || 0) <= 0 && +Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; +if (simple) { +this._drawSimplePoints(g, xm, ym, opacityScale); +return; +} +const gl = this.gl; +const prog = this.pointProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); +this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); +gl.uniform1f(u("u_dpr"), this.dpr); +gl.uniform1f(u("u_size"), g.size); +gl.uniform1i(u("u_sizeMode"), g.sizeMode); +gl.uniform2f(u("u_sizeRange"), g.sizeRange[0], g.sizeRange[1]); +gl.uniform1i(u("u_colorMode"), g.colorMode); +const markOpacity = this._fillOpacity(g.trace.style, 0.8) * opacityScale; +gl.uniform1f(u("u_opacity"), markOpacity); +gl.uniform1f(u("u_selectedOpacity"), this._markStateNumber("selected", "opacity", 1)); +gl.uniform1f(u("u_unselectedOpacity"), this._markStateNumber("unselected", "opacity", 0.12)); +const stateColor = (loc, expr) => { +const c = expr ? parseColor(this.root, expr, [0, 0, 0, 1]) : null; +gl.uniform4f(loc, c ? c[0] : 0, c ? c[1] : 0, c ? c[2] : 0, c ? 1 : 0); +}; +stateColor(u("u_selColor"), this._markStateValue("selected", "color")); +stateColor(u("u_unselColor"), this._markStateValue("unselected", "color")); +const [r, gg, b] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, 1); +gl.uniform1i(u("u_symbol"), g.symbol || 0); +const sc = g.pointStroke; +const strokeAlpha = sc +? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale +: 0; +gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr); +gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0); +gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0, +sc ? sc[2] * strokeAlpha : 0, strokeAlpha); +gl.uniform1i(u("u_selActive"), g.selActive ? 1 : 0); +const colorOn = g.colorMode !== 0 && g.cBuf; +const sizeOn = g.sizeMode === 1 && g.sBuf; +const selOn = g.selActive && g.selBuf; +if (g.lut) { +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, g.lut); +gl.uniform1i(u("u_lut"), 0); +} +const blendTarget = g.lodBlend ?? 0; +let blend = g.lodBlendShown ?? blendTarget; +if (Math.abs(blend - blendTarget) > 0.005 && !this._prefersReducedMotion()) { +const now = this._now(); +const dt = g._blendTick ? Math.min(100, now - g._blendTick) : 16; +g._blendTick = now; +blend += (blendTarget - blend) * (1 - Math.exp(-dt / 90)); +g.lodBlendShown = blend; +this.draw(); +} else { +g.lodBlendShown = blend = blendTarget; +g._blendTick = 0; +} +gl.uniform1f(u("u_dblend"), blend); +const blendOn = blend > 0.001 && g.dBuf && g.dlut; +if (blendOn) { +gl.activeTexture(gl.TEXTURE1); +gl.bindTexture(gl.TEXTURE_2D, g.dlut); +} +gl.uniform1i(u("u_dlut"), 1); +this._bindVao( +g, +"points", +[ +g.xBuf._fcId, g.yBuf._fcId, +colorOn ? g.cBuf._fcId : 0, +sizeOn ? g.sBuf._fcId : 0, +selOn ? g.selBuf._fcId : 0, +blendOn ? g.dBuf._fcId : 0, +], +() => { +this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); +this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); +if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 0); +if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, g.sBuf, 0, 0); +if (selOn) this._vaoAttr(ATTR_SLOTS.a_sel, g.selBuf, 0, 0); +if (blendOn) this._vaoAttr(ATTR_SLOTS.a_dval, g.dBuf, 0, 0); +} +); +if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); +if (!selOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1.0); +if (!blendOn) gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); +gl.drawArrays(gl.POINTS, 0, g.n); +} +_drawSimplePoints(g, xm, ym, opacityScale = 1) { +const gl = this.gl; +const prog = this.pointSimpleProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); +this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); +gl.uniform1f(u("u_dpr"), this.dpr); +gl.uniform1f(u("u_size"), g.size); +const [r, gg, b] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale); +this._bindVao( +g, +"points-simple", +[g.xBuf._fcId, g.yBuf._fcId], +() => { +this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); +this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); +} +); +gl.drawArrays(gl.POINTS, 0, g.n); +} +_drawHoverState() { +const hit = this._hoverTarget; +if (!hit || !hit.g) return; +const g = hit.g; +if (g.trace.kind !== "scatter" || g.tier === "density") return; +if (!Number.isInteger(hit.index) || hit.index < 0 || hit.index >= g.n) return; +const [x0, x1] = this._axisRange(g.xAxis); +const [y0, y1] = this._axisRange(g.yAxis); +this._drawHoverPoint( +g, +hit.index, +this._map(g.xMeta, x0, x1, g.xAxis), +this._map(g.yMeta, y0, y1, g.yAxis) +); +} +_drawHoverPoint(g, index, xm, ym) { +const gl = this.gl; +const prog = this.pointProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); +this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); +const defaultSize = Math.max((g.size || 4) * 1.75, (g.size || 4) + 5); +const size = Math.max(0, this._markStateNumber("hover", "size", defaultSize)); +const opacity = Math.max(0, Math.min(1, this._markStateNumber("hover", "opacity", 0.95))); +const color = parseColor( +this.root, +this._markStatePaint("hover", "color", "rgba(15,23,42,.92)"), +[0.06, 0.09, 0.16, 0.92] +); +gl.uniform1f(u("u_dpr"), this.dpr); +gl.uniform1f(u("u_size"), size); +gl.uniform1i(u("u_sizeMode"), 0); +gl.uniform2f(u("u_sizeRange"), size, size); +gl.uniform1i(u("u_colorMode"), 0); +gl.uniform1f(u("u_opacity"), opacity); +gl.uniform1f(u("u_selectedOpacity"), 1); +gl.uniform1f(u("u_unselectedOpacity"), 1); +gl.uniform4f(u("u_color"), color[0], color[1], color[2], 1); +gl.uniform1i(u("u_selActive"), 0); +gl.uniform1f(u("u_dblend"), 0); +this._bindVao(g, "hover", [g.xBuf._fcId, g.yBuf._fcId], () => { +this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); +this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); +}); +gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); +gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1); +gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); +gl.drawArrays(gl.POINTS, index, 1); +} +_drawDensity(g, density, opacityScale = 1) { +const gl = this.gl; +const prog = this.densityProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +const { x0, x1, y0, y1 } = this.view; +const [vx0, vx1] = this._axisRange(g.xAxis); +const [vy0, vy1] = this._axisRange(g.yAxis); +gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); +gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); +gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); +const d = density || g.density; +gl.uniform4f(u("u_gridRange"), d.xRange[0], d.xRange[1], d.yRange[0], d.yRange[1]); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale); +const constant = d.color; +gl.uniform1i(u("u_constantColor"), constant ? 1 : 0); +gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1])); +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, d.tex); +gl.uniform1i(u("u_grid"), 0); +gl.activeTexture(gl.TEXTURE1); +gl.bindTexture(gl.TEXTURE_2D, d.lut); +gl.uniform1i(u("u_lut"), 1); +gl.bindVertexArray(this.quadVao); +gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); +} +_drawHeatmap(g) { +const h = g.heatmap; +if (!h) return; +const gl = this.gl; +const prog = this.heatmapProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +const { x0, x1, y0, y1 } = this.view; +const [vx0, vx1] = this._axisRange(g.xAxis); +const [vy0, vy1] = this._axisRange(g.yAxis); +gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); +gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); +gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); +gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); +gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, h.tex); +gl.uniform1i(u("u_grid"), 0); +if (!h.truecolor) { +gl.activeTexture(gl.TEXTURE1); +gl.bindTexture(gl.TEXTURE_2D, h.lut); +gl.uniform1i(u("u_lut"), 1); +} +gl.bindVertexArray(this.quadVao); +gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); +} +_drawLine(g, xm, ym, color = null, width = null, opacity = null) { +if (g.n < 2) return; +const gl = this.gl; +gl.useProgram(this.lineProg); +const u = (n) => uniformOf(gl, this.lineProg, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(this.lineProg, "u_x", g.xMeta, g.xAxis); +this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis); +gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); +gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr); +const [r, gg, b, a] = color || g.color; +const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1); +gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity); +const dashed = this._lineDash(g); +this._bindVao( +g, +"line", +dashed ? [g.xBuf._fcId, g.yBuf._fcId, g._lenBuf._fcId] : [g.xBuf._fcId, g.yBuf._fcId], +() => { +this._vaoAttr(ATTR_SLOTS.ax0, g.xBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ax1, g.xBuf, 4, 1); +this._vaoAttr(ATTR_SLOTS.ay0, g.yBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay1, g.yBuf, 4, 1); +if (dashed) { +this._vaoAttr(ATTR_SLOTS.a_len0, g._lenBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.a_len1, g._lenBuf, 4, 1); +} +} +); +gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n - 1); +} +_drawSegments(g, xm, ym) { +if (g.n < 1) return; +const gl = this.gl; +const prog = this.segmentProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); +this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); +this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); +this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); +gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); +gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style)); +gl.uniform1i(u("u_colorMode"), g.colorMode || 0); +const dashed = this._segmentDash(g, prog); +if (g.colorMode && g.lut) { +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, g.lut); +gl.uniform1i(u("u_lut"), 0); +} +this._bindVao( +g, +"segment", +[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, +g.colorMode ? g.cBuf._fcId : 0, +dashed ? g._segmentDashOffsetBuf._fcId : 0, +dashed ? g._segmentDashDirBuf._fcId : 0], +() => { +this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); +if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (dashed) { +this._vaoAttr(ATTR_SLOTS.a_dash0, g._segmentDashOffsetBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.a_dashDir, g._segmentDashDirBuf, 0, 1); +} +} +); +if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); +} +_segmentDash(g, prog) { +const gl = this.gl; +const u = (n) => uniformOf(gl, prog, n); +const dash = g.trace.style && g.trace.style.dash; +const cpu = g._segmentCpu; +if (!dash || !dash.length || !cpu) { +gl.uniform1i(u("u_dashCount"), 0); +return false; +} +const n = g.n; +const offsets = g._segmentDashOffsets?.length === n +? g._segmentDashOffsets : (g._segmentDashOffsets = new Float32Array(n)); +const directions = g._segmentDashDirections?.length === n +? g._segmentDashDirections : (g._segmentDashDirections = new Float32Array(n)); +const k0 = new Array(n), k1 = new Array(n), lengths = new Float32Array(n); +const adjacency = new Map(); +const add = (key, index) => { +const edges = adjacency.get(key); +if (edges) edges.push(index); else adjacency.set(key, [index]); +}; +const key = (x, y) => `${Math.round(x * 1000)},${Math.round(y * 1000)}`; +const dpr = this.dpr; +for (let i = 0; i < n; i++) { +const x0 = this._dataPx(g.xAxis, this._decodeValue(cpu.x0, g.x0Meta, i)); +const x1 = this._dataPx(g.xAxis, this._decodeValue(cpu.x1, g.x1Meta, i)); +const y0 = this._dataPx(g.yAxis, this._decodeValue(cpu.y0, g.y0Meta, i)); +const y1 = this._dataPx(g.yAxis, this._decodeValue(cpu.y1, g.y1Meta, i)); +k0[i] = key(x0, y0); k1[i] = key(x1, y1); +lengths[i] = Math.hypot(x1 - x0, y1 - y0) * dpr; +add(k0[i], i); add(k1[i], i); +} +const visited = new Uint8Array(n); +const walk = (start) => { +let current = start, accumulated = 0; +while (true) { +const edge = (adjacency.get(current) || []).find((index) => !visited[index]); +if (edge === undefined) break; +visited[edge] = 1; +if (k0[edge] === current) { +offsets[edge] = accumulated; +directions[edge] = 1; +current = k1[edge]; +} else { +offsets[edge] = accumulated + lengths[edge]; +directions[edge] = -1; +current = k0[edge]; +} +accumulated += lengths[edge]; +} +}; +for (const [node, edges] of adjacency) if (edges.length === 1) walk(node); +for (let i = 0; i < n; i++) if (!visited[i]) walk(k0[i]); +const upload = (buffer, values) => { +if (!buffer) return this._upload(values); +gl.bindBuffer(gl.ARRAY_BUFFER, buffer); +gl.bufferData(gl.ARRAY_BUFFER, values, gl.DYNAMIC_DRAW); +return buffer; +}; +g._segmentDashOffsetBuf = upload(g._segmentDashOffsetBuf, offsets); +g._segmentDashDirBuf = upload(g._segmentDashDirBuf, directions); +const pattern = new Float32Array(8); +const count = Math.min(dash.length, 8); +let period = 0; +for (let i = 0; i < count; i++) { +pattern[i] = Number(dash[i]) * dpr; +period += pattern[i]; +} +gl.uniform1i(u("u_dashCount"), count); +gl.uniform1fv(u("u_dashArr"), pattern); +gl.uniform1f(u("u_dashPeriod"), Math.max(period, 1e-3)); +return true; +} +_drawMesh(g, xm, ym) { +if (g.n < 1) return; +const gl = this.gl; +const prog = this.meshProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis); +for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); +gl.uniform1i(u("u_colorMode"), g.colorMode || 0); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1); +const stroke = g.meshStroke || [0, 0, 0, 0]; +const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style); +gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha, +stroke[2] * strokeAlpha, strokeAlpha); +gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); +if (g.colorMode && g.lut) { +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, g.lut); +gl.uniform1i(u("u_lut"), 0); +} +const parts = ["x0", "x1", "x2", "y0", "y1", "y2"].map((name) => g[name + "Buf"]._fcId); +parts.push(g.colorMode ? g.cBuf._fcId : 0); +this._bindVao(g, "mesh", parts, () => { +for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { +this._vaoAttr(ATTR_SLOTS["a" + name], g[name + "Buf"], 0, 1); +} +if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +}); +if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, g.n); +} +_lineDash(g) { +const gl = this.gl; +const u = (n) => uniformOf(gl, this.lineProg, n); +const dash = g.trace.style && g.trace.style.dash; +if (!dash || !dash.length || !g._dashX) { +gl.uniform1i(u("u_dashCount"), 0); +return false; +} +const n = g.n; +if (!g._lenArr || g._lenArr.length !== n) g._lenArr = new Float32Array(n); +const lens = g._lenArr; +const dpr = this.dpr; +let px = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, 0)); +let py = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, 0)); +let acc = 0; +lens[0] = 0; +for (let i = 1; i < n; i++) { +const nx = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, i)); +const ny = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, i)); +if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(px) && Number.isFinite(py)) { +acc += Math.hypot(nx - px, ny - py) * dpr; +} +lens[i] = acc; +px = nx; +py = ny; +} +if (!g._lenBuf) g._lenBuf = this._upload(lens); +else { +gl.bindBuffer(gl.ARRAY_BUFFER, g._lenBuf); +gl.bufferData(gl.ARRAY_BUFFER, lens, gl.DYNAMIC_DRAW); +} +const arr = new Float32Array(8); +let period = 0; +const count = Math.min(dash.length, 8); +for (let i = 0; i < count; i++) { +arr[i] = dash[i] * dpr; +period += arr[i]; +} +gl.uniform1i(u("u_dashCount"), count); +gl.uniform1fv(u("u_dashArr"), arr); +gl.uniform1f(u("u_dashPeriod"), Math.max(period, 1e-3)); +return true; +} +_drawArea(g, xm, ym, bm) { +if (g.n < 2) return; +const gl = this.gl; +const prog = this.areaProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +gl.uniform2f(u("u_bmap"), bm[0], bm[1]); +this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); +this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); +this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.35)); +gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); +this._setGradientUniforms(prog, g.grad); +this._bindVao(g, "area", [g.xBuf._fcId, g.yBuf._fcId, g.baseBuf._fcId], () => { +this._vaoAttr(ATTR_SLOTS.ax0, g.xBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ax1, g.xBuf, 4, 1); +this._vaoAttr(ATTR_SLOTS.ay0, g.yBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay1, g.yBuf, 4, 1); +this._vaoAttr(ATTR_SLOTS.ab0, g.baseBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ab1, g.baseBuf, 4, 1); +}); +gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n - 1); +} +_drawRects(g, x0, x1, y0, y1, edgePad = [0, 0, 0, 0]) { +if (!g.n) return; +const gl = this.gl; +const prog = this.rectProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_x0map"), x0[0], x0[1]); +gl.uniform2f(u("u_x1map"), x1[0], x1[1]); +gl.uniform2f(u("u_y0map"), y0[0], y0[1]); +gl.uniform2f(u("u_y1map"), y1[0], y1[1]); +this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); +this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); +this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); +this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); +gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); +gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); +gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform1i(u("u_colorMode"), g.colorMode || 0); +this._setRectStyleUniforms(prog, g); +const colorOn = g.colorMode && g.cBuf; +if (colorOn) { +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, g.lut); +gl.uniform1i(u("u_lut"), 0); +} +this._bindVao( +g, +"rects", +[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, colorOn ? g.cBuf._fcId : 0], +() => { +this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); +this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); +if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +} +); +if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); +} +_drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { +if (!g.n) return; +const gl = this.gl; +const prog = this.barProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform2f(u("u_pmap"), pmap[0], pmap[1]); +gl.uniform2f(u("u_v1map"), v1map[0], v1map[1]); +gl.uniform2f(u("u_v0map"), v0map ? v0map[0] : 1, v0map ? v0map[1] : 0); +const pAxis = g.orientation === 1 ? g.yAxis : g.xAxis; +const vAxis = g.orientation === 1 ? g.xAxis : g.yAxis; +this._setAxisUniforms(prog, "u_p", g.posMeta, pAxis); +this._setAxisUniforms(prog, "u_v1", g.value1Meta, vAxis); +this._setAxisUniforms(prog, "u_v0", g.value0Meta, vAxis); +gl.uniform1i(u("u_pmode"), this._axisMode(pAxis)); +gl.uniform1i(u("u_vmode"), this._axisMode(vAxis)); +gl.uniform1f(u("u_width"), g.width); +gl.uniform1i(u("u_orientation"), g.orientation); +gl.uniform1i(u("u_v0Mode"), g.value0Mode); +gl.uniform1f(u("u_v0Const"), v0Const ?? 0); +gl.uniform1f(u("u_v0EdgePad"), v0EdgePad); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform1i(u("u_colorMode"), g.colorMode || 0); +this._setRectStyleUniforms(prog, g); +const v0On = g.value0Mode === 1 && g.value0Buf; +const colorOn = g.colorMode && g.cBuf; +if (colorOn) { +gl.activeTexture(gl.TEXTURE0); +gl.bindTexture(gl.TEXTURE_2D, g.lut); +gl.uniform1i(u("u_lut"), 0); +} +this._bindVao( +g, +"bars", +[ +g.posBuf._fcId, g.value1Buf._fcId, +v0On ? g.value0Buf._fcId : 0, +colorOn ? g.cBuf._fcId : 0, +], +() => { +this._vaoAttr(ATTR_SLOTS.a_pos, g.posBuf, 0, 1); +this._vaoAttr(ATTR_SLOTS.a_v1, g.value1Buf, 0, 1); +if (v0On) this._vaoAttr(ATTR_SLOTS.a_v0, g.value0Buf, 0, 1); +if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +} +); +if (!v0On) gl.vertexAttrib1f(ATTR_SLOTS.a_v0, 0); +if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); +} +_dataPxX(value) { +return this._dataPx("x", value); +} +_dataPxY(value) { +return this._dataPx("y", value); +} +_styleNumber(style, key, fallback) { +if (!style || typeof style !== "object") return fallback; +const value = Number(style[key]); +return Number.isFinite(value) ? value : fallback; +} +_axisStyleNumber(axis, key, fallback) { +return this._styleNumber(axis && axis.style, key, fallback); +} +_axisStylePaint(axis, key, fallback) { +const style = axis && typeof axis.style === "object" ? axis.style : null; +return safeCssPaint(this.root, style && style[key], fallback); +} +_axisStyleValue(axis, key) { +const style = axis && typeof axis.style === "object" ? axis.style : null; +return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined; +} +_axisGridDash(axis) { +const value = String(this._axisStyleValue(axis, "grid_dash") || "solid"); +if (value === "dashed") return [6, 4]; +if (value === "dotted") return [1, 3]; +if (value === "dashdot") return [6, 3, 1, 3]; +return []; +} +_axisTickLabelStrategy(axis) { +const raw = axis && axis.tick_label_strategy !== undefined +? axis.tick_label_strategy +: this._axisStyleValue(axis, "tick_label_strategy"); +const value = String(raw || "auto").replace(/-/g, "_"); +return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; +} +_axisTickLabelAngle(axis) { +const raw = axis && axis.tick_label_angle !== undefined +? axis.tick_label_angle +: this._axisStyleValue(axis, "tick_label_angle"); +const angle = Number(raw); +return Number.isFinite(angle) ? angle : null; +} +_axisTickLabelMinGap(axis, dim) { +const raw = axis && axis.tick_label_min_gap !== undefined +? axis.tick_label_min_gap +: this._axisStyleValue(axis, "tick_label_min_gap"); +const gap = Number(raw); +return Number.isFinite(gap) && gap >= 0 ? gap : (dim === "x" ? 8 : 4); +} +_estimateTickLabel(text, fontSize) { +const s = String(text || ""); +return { w: Math.max(fontSize * 0.7, s.length * fontSize * 0.62), h: fontSize * 1.2 }; +} +_tickLabelExtent(label, dim, fontSize) { +const size = this._estimateTickLabel(label.text, fontSize); +const angle = Math.abs(Number(label.angle || 0)) * Math.PI / 180; +return dim === "y" +? Math.abs(Math.sin(angle)) * size.w + Math.abs(Math.cos(angle)) * size.h +: Math.abs(Math.cos(angle)) * size.w + Math.abs(Math.sin(angle)) * size.h; +} +_tickLabelsCollide(labels, dim, fontSize, minGap) { +const rows = new Map(); +for (const label of labels) { +const row = Number(label.row || 0); +if (!rows.has(row)) rows.set(row, []); +rows.get(row).push(label); +} +for (const rowLabels of rows.values()) { +rowLabels.sort((a, b) => a.pos - b.pos); +let lastEnd = -Infinity; +for (const label of rowLabels) { +const extent = this._tickLabelExtent(label, dim, fontSize); +const start = label.pos - extent / 2; +const end = label.pos + extent / 2; +if (start < lastEnd + minGap) return true; +lastEnd = end; +} +} +return false; +} +_downsampleTickLabels(labels, dim, fontSize, minGap) { +if (labels.length <= 1) return labels; +for (let stride = 2; stride <= labels.length; stride++) { +const out = labels.filter((_, i) => i % stride === 0); +if (!this._tickLabelsCollide(out, dim, fontSize, minGap)) return out; +} +return labels.slice(0, 1); +} +_layoutTickLabels(axis, dim, labels) { +if (labels.length <= 1) return labels.map((label) => ({ ...label, angle: 0, row: 0 })); +const fontSize = Math.max( +8, +this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)), +); +const minGap = this._axisTickLabelMinGap(axis, dim); +const explicitAngle = this._axisTickLabelAngle(axis); +const baseAngle = explicitAngle === null ? 0 : explicitAngle; +const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); +let strategy = this._axisTickLabelStrategy(axis); +if (strategy === "none") return []; +if (strategy === "off") return []; +if (strategy === "auto") { +if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap)) return withBase; +if (dim === "x" && axis.kind === "category" && labels.length <= 16) strategy = "rotate"; +else if (dim === "x" && labels.length <= 24) strategy = "stagger"; +else strategy = "hide"; +} +let out = withBase; +if (strategy === "rotate" && dim === "x") { +const angle = explicitAngle === null ? (axis.side === "top" ? 35 : -35) : explicitAngle; +out = labels.map((label) => ({ ...label, angle, row: 0 })); +} else if (strategy === "stagger" && dim === "x") { +out = labels.map((label, i) => ({ ...label, angle: baseAngle, row: i % 2 })); +} +if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap)) { +out = this._downsampleTickLabels(out, dim, fontSize, minGap); +} +return out; +} +_axisLabelCss(axis, dim, fallbackCss) { +const rawPosition = axis && axis.label_position; +const hasPosition = rawPosition !== undefined && rawPosition !== null; +const hasOffset = axis && Number.isFinite(Number(axis.label_offset)); +const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); +if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; +if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { +return { css: "font-weight:500;white-space:nowrap;", style: rawPosition }; +} +const p = this.plot; +const position = String(hasPosition ? rawPosition : "center").replace(/-/g, "_"); +const inside = position.startsWith("inside_"); +const anchor = inside ? position.slice("inside_".length) : position; +const offset = hasOffset ? Number(axis.label_offset) : 0; +const side = axis && axis.side; +const anchorFrac = anchor === "start" ? 0 : (anchor === "end" ? 1 : 0.5); +if (dim === "x") { +const x = p.x + p.w * anchorFrac; +const outsideY = side === "top" ? p.y - 34 : p.y + p.h + 24; +const insideY = side === "top" ? p.y + 12 : p.y + p.h - 12; +const y = (inside ? insideY : outsideY) + +(side === "top" ? (inside ? offset : -offset) : (inside ? -offset : offset)); +const translateX = anchor === "start" ? 0 : (anchor === "end" ? -100 : -50); +const angle = hasAngle ? Number(axis.label_angle) : 0; +return { +css: +`left:${x}px;top:${y}px;` + +`transform:translateX(${translateX}%) rotate(${angle}deg);` + +"transform-origin:center;font-weight:500;white-space:nowrap;", +style: null, +}; +} +const xOutside = side === "right" ? p.x + p.w + 40 : 10; +const xInside = side === "right" ? p.x + p.w - 12 : p.x + 12; +const x = (inside ? xInside : xOutside) + +(side === "right" ? (inside ? -offset : offset) : (inside ? offset : -offset)); +const y = p.y + p.h * (1 - anchorFrac); +const angle = hasAngle ? Number(axis.label_angle) : (side === "right" ? 90 : -90); +return { +css: +`left:${x}px;top:${y}px;` + +`transform:translate(-50%,-50%) rotate(${angle}deg);` + +"transform-origin:center;font-weight:500;white-space:nowrap;", +style: null, +}; +} +_drawChrome() { +const s = this.spec; +const dpr = this.dpr; +const ctx = this.chrome.getContext("2d"); +ctx.setTransform(dpr, 0, 0, dpr, 0, 0); +ctx.clearRect(0, 0, this.size.w, this.size.h); +const now = this._now(); +const labelCadenceMs = this._viewAnim ? 80 : 0; +const updateLabels = labelCadenceMs === 0 +|| this._lastLabelDraw === null +|| now - this._lastLabelDraw >= labelCadenceMs; +if (updateLabels) { +this.labels.textContent = ""; +this._lastLabelDraw = now; +} +const p = this.plot; +const xAxis = this._axis("x"); +const yAxis = this._axis("y"); +const hideX = this._axisTickLabelStrategy(xAxis) === "none"; +const hideY = this._axisTickLabelStrategy(yAxis) === "none"; +const xt = this._axisTicks( +"x", +this._axisTickTarget("x", Math.max(3, p.w / (xAxis.kind === "time" ? 90 : 80))), +); +const yt = this._axisTicks("y", this._axisTickTarget("y", Math.max(3, p.h / 45))); +const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(px) + 0.5)); +const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5)); +ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid); +ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1)); +ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1); +ctx.setLineDash(this._axisGridDash(xAxis)); +ctx.beginPath(); +for (const v of (hideX ? [] : xt.ticks)) { +const px = this._dataPx("x", v); +if (!Number.isFinite(px)) continue; +const x = xEdge(px); +ctx.moveTo(x, p.y); +ctx.lineTo(x, p.y + p.h); +} +ctx.stroke(); +ctx.strokeStyle = this._axisStylePaint(yAxis, "grid_color", this.theme.grid); +ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(yAxis, "grid_width", 1)); +ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1); +ctx.setLineDash(this._axisGridDash(yAxis)); +ctx.beginPath(); +for (const v of (hideY ? [] : yt.ticks)) { +const py = this._dataPx("y", v); +if (!Number.isFinite(py)) continue; +const y = yEdge(py); +ctx.moveTo(p.x, y); +ctx.lineTo(p.x + p.w, y); +} +ctx.stroke(); +ctx.globalAlpha = 1; +ctx.setLineDash([]); +this._drawAnnotationShapes(ctx); +if (updateLabels) { +const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => { +const d = document.createElement("div"); +d.style.cssText = +`position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;` + +`background:${this._axisStylePaint(styleAxis, colorKey, this.theme.axis)};` + +"pointer-events:none;"; +this.labels.appendChild(d); +}; +const frameSides = Array.isArray(s.frame_sides) +? s.frame_sides +: [xAxis.side || "bottom", yAxis.side || "left"]; +if (!hideY) { +const yWidth = Math.max(1, this._axisStyleNumber(yAxis, "axis_width", 1)); +if (frameSides.includes("left")) rule(yAxis, p.x, p.y, yWidth, p.h); +if (frameSides.includes("right")) rule(yAxis, p.x + p.w - yWidth, p.y, yWidth, p.h); +} +if (!hideX) { +const xHeight = Math.max(1, this._axisStyleNumber(xAxis, "axis_width", 1)); +if (frameSides.includes("top")) rule(xAxis, p.x, p.y, p.w, xHeight); +if (frameSides.includes("bottom")) rule(xAxis, p.x, p.y + p.h - xHeight, p.w, xHeight); +} +for (const axis of Object.values(this.axes)) { +if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; +const w = Math.max(1, this._axisStyleNumber(axis, "axis_width", 1)); +const x = axis.side === "left" ? p.x : p.x + p.w - w; +rule(axis, x, p.y, w, p.h); +} +const tickParts = (axis) => { +const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); +const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); +const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); +if (direction === "in") return { inward: length, outward: 0, width }; +if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; +return { inward: 0, outward: length, width }; +}; +if (!hideX) { +const tick = tickParts(xAxis); +const side = xAxis.side || "bottom"; +const edge = side === "top" ? p.y : p.y + p.h; +for (const value of xt.ticks) { +const x = this._dataPx("x", value); +if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; +const top = side === "top" ? edge - tick.outward : edge - tick.inward; +rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); +} +} +if (!hideY) { +const tick = tickParts(yAxis); +const side = yAxis.side || "left"; +const edge = side === "right" ? p.x + p.w : p.x; +for (const value of yt.ticks) { +const y = this._dataPx("y", value); +if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; +const left = side === "right" ? edge - tick.inward : edge - tick.outward; +rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); +} +} +} +const label = (text, css, axis, kind = "tick", extraStyle = null) => { +if (!updateLabels) return; +const d = document.createElement("div"); +d.textContent = text; +d.dataset.fcLabelKind = kind; +d.dataset.fcAxis = axis && axis.id !== undefined ? String(axis.id) : ""; +d.dataset.fcAxisSide = axis && axis.side ? String(axis.side) : ""; +const colorKey = kind === "label" +? "label_color" +: (this._axisStyleValue(axis, "tick_label_color") !== undefined +? "tick_label_color" : "tick_color"); +const sizeKey = kind === "label" +? "label_size" +: (this._axisStyleValue(axis, "tick_label_size") !== undefined +? "tick_label_size" : "tick_size"); +let color = ""; +if (this._axisStyleValue(axis, colorKey) !== undefined) { +color = `color:${this._axisStylePaint(axis, colorKey, this.theme.label)};`; +} +let size = ""; +if (this._axisStyleValue(axis, sizeKey) !== undefined) { +size = `font-size:${Math.max(8, this._axisStyleNumber(axis, sizeKey, 11))}px;`; +} +d.style.cssText = `position:absolute;line-height:1.2;white-space:nowrap;${color}${size}${css}`; +this._applySlot(d, kind === "label" ? "axis_title" : "tick_label"); +this._applyStyle(d, extraStyle); +this.labels.appendChild(d); +}; +const xLabelCandidates = []; +for (const v of (xt.labels || xt.ticks)) { +const px = this._dataPx("x", v); +if (px < p.x - 1 || px > p.x + p.w + 1) continue; +const text = this._axisTickText(xAxis, v, xt.step); +xLabelCandidates.push({ pos: px, text }); +} +for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { +const tickLabelSize = this._axisStyleNumber( +xAxis, +"tick_label_size", +this._axisStyleNumber(xAxis, "tick_size", 11), +); +const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); +const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; +const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`; +const origin = xAxis.side === "top" ? "bottom center" : "top center"; +label( +item.text, +`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${origin};`, +xAxis, +); +} +const yLabelCandidates = []; +for (const v of (yt.labels || yt.ticks)) { +const py = this._dataPx("y", v); +if (py < p.y - 1 || py > p.y + p.h + 1) continue; +const text = this._axisTickText(yAxis, v, yt.step); +yLabelCandidates.push({ pos: py, text }); +} +for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { +const angle = Number(item.angle || 0); +const css = yAxis.side === "right" +? `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;` +: `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;`; +label(item.text, css, yAxis); +} +for (const axis of Object.values(this.axes)) { +if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; +const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); +const labelCandidates = []; +for (const v of (ticks.labels || ticks.ticks)) { +const py = this._dataPx(axis.id, v); +if (py < p.y - 1 || py > p.y + p.h + 1) continue; +const text = this._axisTickText(axis, v, ticks.step); +labelCandidates.push({ pos: py, text }); +} +for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { +const angle = Number(item.angle || 0); +const css = axis.side === "left" +? `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;` +: `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;`; +label(item.text, css, axis); +} +if (axis.label) { +const fallbackCss = axis.side === "left" +? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;` +: `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`; +const placement = this._axisLabelCss(axis, "y", fallbackCss); +label(axis.label, placement.css, axis, "label", placement.style); +} +} +if (s.x_axis.label) { +const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; +const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; +const placement = this._axisLabelCss(xAxis, "x", fallbackCss); +label(s.x_axis.label, placement.css, xAxis, "label", placement.style); +} +if (s.y_axis.label) { +const fallbackCss = yAxis.side === "right" +? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;` +: `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`; +const placement = this._axisLabelCss(yAxis, "y", fallbackCss); +label(s.y_axis.label, placement.css, yAxis, "label", placement.style); +} +this._drawAnnotationLabels(updateLabels); +} +_transitionActive() { +const activeStart = (v) => v !== undefined && v !== null; +return !!this._viewAnim || this.gpuTraces.some((g) => +activeStart(g._densityFadeStart) || +activeStart(g._densitySwitchFadeStart) || +activeStart(g._drillFadeStart) || +activeStart(g._drillExitFadeStart) || +!!g._densityNormAnim); +} +_renderPick() { +const gl = this.gl; +if (this._pickW !== this.canvas.width || this._pickH !== this.canvas.height) { +this._allocPickTex(); +} +gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); +gl.viewport(0, 0, this.canvas.width, this.canvas.height); +gl.disable(gl.BLEND); +gl.clearColor(0, 0, 0, 0); +gl.clear(gl.COLOR_BUFFER_BIT); +const { x0, x1, y0, y1 } = this.view; +const prog = this.pickProg; +gl.useProgram(prog); +const u = (n) => uniformOf(gl, prog, n); +gl.uniform1f(u("u_dpr"), this.dpr); +let base = 1; +for (const g of this.gpuTraces) { +const pg = g.tier === "density" +? (g.drill && !g._drillDying && this._viewInside(g.drill.win) ? g.drill : null) +: (markOf(g.trace.kind).pointPick ? g : null); +if (!pg || !pg.n || base + pg.n > 0x7fffffff) { +g.pickBase = -1; +g.pickCount = 0; +continue; +} +const [px0, px1] = this._axisRange(pg.xAxis || g.xAxis); +const [py0, py1] = this._axisRange(pg.yAxis || g.yAxis); +const xm = this._map(pg.xMeta, px0, px1, pg.xAxis || g.xAxis); +const ym = this._map(pg.yMeta, py0, py1, pg.yAxis || g.yAxis); +gl.uniform2f(u("u_xmap"), xm[0], xm[1]); +gl.uniform2f(u("u_ymap"), ym[0], ym[1]); +this._setAxisUniforms(prog, "u_x", pg.xMeta, pg.xAxis || g.xAxis); +this._setAxisUniforms(prog, "u_y", pg.yMeta, pg.yAxis || g.yAxis); +gl.uniform1f(u("u_size"), pg.size); +gl.uniform1i(u("u_sizeMode"), pg.sizeMode); +gl.uniform2f(u("u_sizeRange"), pg.sizeRange[0], pg.sizeRange[1]); +gl.uniform1i(u("u_pick_base"), base); +g.pickBase = base; +g.pickCount = pg.n; +const sizeOn = pg.sizeMode === 1 && pg.sBuf; +this._bindVao( +pg, +"pick", +[pg.xBuf._fcId, pg.yBuf._fcId, sizeOn ? pg.sBuf._fcId : 0], +() => { +this._vaoAttr(ATTR_SLOTS.ax, pg.xBuf, 0, 0); +this._vaoAttr(ATTR_SLOTS.ay, pg.yBuf, 0, 0); +if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, pg.sBuf, 0, 0); +} +); +if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); +gl.drawArrays(gl.POINTS, 0, pg.n); +base += pg.n; +} +gl.enable(gl.BLEND); +gl.bindFramebuffer(gl.FRAMEBUFFER, null); +this._pickDirty = false; +} +_pickAt(cssX, cssY) { +if (!this._pickable) return null; +if (this._pickDirty) this._renderPick(); +const gl = this.gl; +const px = Math.round(cssX * this.dpr); +const py = Math.round((this.plot.h - cssY) * this.dpr); +if (px < 0 || py < 0 || px >= this.canvas.width || py >= this.canvas.height) return null; +const buf = new Uint8Array(4); +gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); +gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); +gl.bindFramebuffer(gl.FRAMEBUFFER, null); +const id = buf[0] + buf[1] * 0x100 + buf[2] * 0x10000 + buf[3] * 0x1000000; +if (id === 0) return null; +const g = this.gpuTraces.find( +(t) => t.pickBase > 0 && id >= t.pickBase && id < t.pickBase + t.pickCount +); +if (!g) return null; +return { trace: g.trace.id, index: id - g.pickBase, g }; +} +_decodeValue(values, meta, index) { +if (!values || !meta || index < 0 || index >= values.length) return NaN; +return values[index] / (meta.scale || 1) + meta.offset; +} +_dataFromCanvas(cssX, cssY, xAxisId = "x", yAxisId = "y") { +const [x0, x1] = this._axisRange(xAxisId); +const [y0, y1] = this._axisRange(yAxisId); +const xAxis = this._axis(xAxisId); +const yAxis = this._axis(yAxisId); +const cx0 = this._axisCoord(xAxis, x0); +const cx1 = this._axisCoord(xAxis, x1); +const cy0 = this._axisCoord(yAxis, y0); +const cy1 = this._axisCoord(yAxis, y1); +if (![cx0, cx1, cy0, cy1].every(Number.isFinite)) return [NaN, NaN]; +return [ +this._axisValue(xAxis, cx0 + (cssX / this.plot.w) * (cx1 - cx0)), +this._axisValue(yAxis, cy1 - (cssY / this.plot.h) * (cy1 - cy0)), +]; +} +_nearestCpuIndex(g, dataX) { +const cpu = g && g._cpu; +if (!cpu || !cpu.x || !cpu.x.length) return -1; +const xMeta = cpu.xMeta || g.xMeta; +const axis = this._axis(g.xAxis); +const target = this._axisCoord(axis, dataX); +let best = -1; +let bestDist = Infinity; +const limit = Math.min(cpu.x.length, g.n || cpu.x.length); +for (let i = 0; i < limit; i++) { +const x = this._decodeValue(cpu.x, xMeta, i); +const d = Math.abs(this._axisCoord(axis, x) - target); +if (d < bestDist) { +bestDist = d; +best = i; +} +} +return best; +} +_hoverAt(cssX, cssY) { +const maxPx = 12; +let best = null; +for (const g of this.gpuTraces) { +if (g.tier === "density") continue; +const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); +if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) continue; +if (g.heatmap && g._cpuHeatmap) { +const hit = this._heatmapHover(g, dataX, dataY); +if (hit) return hit; +continue; +} +if (g.trace.bar && g._cpu) { +const hit = this._barHover(g, dataX, dataY); +if (hit) return hit; +continue; +} +if (g._cpuRect) { +const hit = this._rectHover(g, dataX, dataY); +if (hit) return hit; +continue; +} +if (!g._cpu || !g._cpu.x || !g._cpu.y) continue; +const idx = this._nearestCpuIndex(g, dataX); +if (idx < 0) continue; +const x = this._decodeValue(g._cpu.x, g._cpu.xMeta, idx); +const y = this._decodeValue(g._cpu.y, g._cpu.yMeta, idx); +const px = this._dataPx(g.xAxis, x) - this.plot.x; +const py = this._dataPx(g.yAxis, y) - this.plot.y; +const dist = Math.hypot(px - cssX, py - cssY); +if (dist <= maxPx && (!best || dist < best.dist)) { +best = { trace: g.trace.id, index: idx, g, dist, synthetic: true }; +} +} +return best; +} +_barHover(g, dataX, dataY) { +const cpu = g._cpu; +const horizontal = g.orientation === 1; +const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); +for (let i = 0; i < limit; i++) { +const x = this._decodeValue(cpu.x, cpu.xMeta, i); +const y = this._decodeValue(cpu.y, cpu.yMeta, i); +const value0 = g.value0Mode === 1 && cpu.value0 +? this._decodeValue(cpu.value0, horizontal ? g.value0Meta : g.value0Meta, i) +: g.value0Const; +const lo = Math.min(value0 ?? 0, horizontal ? x : y); +const hi = Math.max(value0 ?? 0, horizontal ? x : y); +if (horizontal) { +if (dataX >= lo && dataX <= hi && Math.abs(dataY - y) <= g.width / 2) { +return { trace: g.trace.id, index: i, g, synthetic: true }; +} +} else if (Math.abs(dataX - x) <= g.width / 2 && dataY >= lo && dataY <= hi) { +return { trace: g.trace.id, index: i, g, synthetic: true }; +} +} +return null; +} +_rectHover(g, dataX, dataY) { +const r = g._cpuRect; +const limit = Math.min(r.x0.length, r.x1.length, r.y0.length, r.y1.length, g.n || r.x0.length); +for (let i = 0; i < limit; i++) { +const x0 = this._decodeValue(r.x0, r.x0Meta, i); +const x1 = this._decodeValue(r.x1, r.x1Meta, i); +const y0 = this._decodeValue(r.y0, r.y0Meta, i); +const y1 = this._decodeValue(r.y1, r.y1Meta, i); +if ( +dataX >= Math.min(x0, x1) && dataX <= Math.max(x0, x1) && +dataY >= Math.min(y0, y1) && dataY <= Math.max(y0, y1) +) { +return { trace: g.trace.id, index: i, g, synthetic: true }; +} +} +return null; +} +_heatmapHover(g, dataX, dataY) { +const h = g.heatmap; +if (!h || !g._cpuHeatmap) return null; +const [x0, x1] = h.xRange; +const [y0, y1] = h.yRange; +if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; +const col = Math.min(h.w - 1, Math.max(0, Math.floor(((dataX - x0) / (x1 - x0)) * h.w))); +const row = Math.min(h.h - 1, Math.max(0, Math.floor(((dataY - y0) / (y1 - y0)) * h.h))); +return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; +} +_drawKeepPick() { +this.draw(true); +} +_hover(e) { +if (this._transitionActive()) { +const hadHover = this._hoverId !== -1; +this._hoverId = -1; +this._hoverTarget = null; +this.tooltip.style.display = "none"; +if (hadHover) this.draw(); +return; +} +const rect = this.canvas.getBoundingClientRect(); +const cssX = e.clientX - rect.left; +const cssY = e.clientY - rect.top; +const hit = this._pickAt(cssX, cssY) || this._hoverAt(cssX, cssY); +if (!hit) { +const hadHover = this._hoverId !== -1; +this._hoverId = -1; +this._hoverTarget = null; +this.tooltip.style.display = "none"; +if (hadHover) this._drawKeepPick(); +return; +} +const id = hit.trace * 1e9 + hit.index; +this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY }; +if (id === this._hoverId) { +this._renderTooltip(this._lastRow, e.clientX, e.clientY); +return; +} +this._hoverId = id; +this._hoverTarget = hit; +this._showTooltip(hit, e.clientX, e.clientY); +this._drawKeepPick(); +} +_asF32(b) { +if (b instanceof ArrayBuffer) return new Float32Array(b); +if (b.byteOffset % 4 === 0) { +return new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); +} +return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +} +_asU8(b) { +if (b instanceof ArrayBuffer) return new Uint8Array(b); +return new Uint8Array(b.buffer, b.byteOffset, b.byteLength); +} +_asU32(b) { +if (b instanceof ArrayBuffer) return new Uint32Array(b); +if (b.byteOffset % 4 === 0) { +return new Uint32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); +} +return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); +} +refreshTheme() { +if (this._destroyed) return; +this.theme = readTheme(this.root); +for (const g of this.gpuTraces) { +markOf(g.trace.kind).refreshColor?.(this, g); +} +this.draw(); +} +destroy() { +if (this._destroyed) return; +this._destroyed = true; +FC_CONTEXT_GOVERNOR.unregister(this); +this._ctxIo?.disconnect(); +this._ctxIo = null; +clearTimeout(this._rebinTimer); +if (this._rebinWorker) { +this._rebinWorker.terminate(); +if (this._rebinWorker._fcUrl) URL.revokeObjectURL(this._rebinWorker._fcUrl); +this._rebinWorker = null; +} +this._ro?.disconnect(); +this._io?.disconnect(); +this._io = null; +this._themeWatch?.removeEventListener?.("change", this._onScheme); +this._dprMq?.removeEventListener?.("change", this._onDprChange); +this._dprMq = null; +this._unsubscribeComm?.(); +this._unsubscribeComm = null; +for (const { target, type, handler, options } of this._listeners.splice(0)) { +target.removeEventListener(type, handler, options); +} +clearTimeout(this._viewTimer); +this._viewTimer = null; +if (this._viewEventRaf) cancelAnimationFrame(this._viewEventRaf); +this._viewEventRaf = null; +if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); +this._wheelZoomRaf = null; +this._pendingWheelZoom = null; +this._linkChannel?.close?.(); +this._linkChannel = null; +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +this._cancelViewAnimation(); +this._destroyGlResources(); +this.gl = null; +this.root.remove(); +} +_deleteBuffers(obj, names) { +const gl = this.gl; +if (!gl || !obj) return; +const seen = new Set(); +for (const name of names) { +const buf = obj[name]; +if (buf && !seen.has(buf)) { +seen.add(buf); +gl.deleteBuffer(buf); +} +obj[name] = null; +} +} +_destroyTraceResources(g, texSeen) { +if (!g) return; +this._destroyDensitySample(g); +this._deleteVaos(g); +this._deleteVaos(g.drill); +this._deleteBuffers(g, [ +"xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", +"x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", +"posBuf", "value1Buf", "value0Buf", +]); +this._deleteBuffers(g.drill, ["xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "dBuf"]); +const textures = []; +if (g.heatmap) textures.push(g.heatmap.tex); +for (const d of g.densityCache || []) textures.push(d && d.tex); +if (g.density) textures.push(g.density.tex); +if (g._shownDensity) textures.push(g._shownDensity.tex); +for (const tex of textures) { +if (tex && !texSeen.has(tex)) { +texSeen.add(tex); +this.gl.deleteTexture(tex); +} +} +g.drill = null; +g.density = null; +g._shownDensity = null; +g.densityCache = []; +g.heatmap = null; +g._cpu = null; +} +_destroyGlResources() { +const gl = this.gl; +if (!gl) return; +const texSeen = new Set(); +for (const g of this.gpuTraces || []) this._destroyTraceResources(g, texSeen); +for (const tex of this._lutCache.values()) { +if (tex && !texSeen.has(tex)) { +texSeen.add(tex); +gl.deleteTexture(tex); +} +} +this._lutCache.clear(); +if (this.pickFbo) gl.deleteFramebuffer(this.pickFbo); +if (this.pickTex && !texSeen.has(this.pickTex)) gl.deleteTexture(this.pickTex); +this.pickFbo = null; +this.pickTex = null; +if (this.quad) gl.deleteBuffer(this.quad); +this.quad = null; +if (this.quadVao) gl.deleteVertexArray(this.quadVao); +this.quadVao = null; +for (const p of this._progCache ? this._progCache.values() : []) { +if (p) gl.deleteProgram(p); +} +if (this._progCache) this._progCache.clear(); +this._glPrograms = this._progCache; +this.gpuTraces = []; +} +} +Object.assign(ChartView.prototype, { +_annotationPaint(style, fallback) { +return safeCssPaint(this.root, style && style.color, fallback); +}, +_annotationLabelPaint(style, fallback) { +return safeCssPaint(this.root, style && (style.label_color || style.color), fallback); +}, +_annotationStrokePaint(style, fallback) { +return safeCssPaint(this.root, style && style.stroke_color, fallback); +}, +_drawAnnotationMarker(ctx, x, y, style, ann) { +if (!Number.isFinite(x) || !Number.isFinite(y)) return; +const r = Math.max(1, this._styleNumber(style, "size", Number(ann.size) || 8) / 2); +const symbol = ["circle", "square", "diamond", "cross"].includes(ann.symbol) ? ann.symbol : "circle"; +ctx.save(); +ctx.globalAlpha = this._styleNumber(style, "opacity", 1); +ctx.fillStyle = this._annotationPaint(style, [0.15, 0.39, 0.92, 1]); +ctx.strokeStyle = symbol === "cross" +? this._annotationPaint(style, [0.15, 0.39, 0.92, 1]) +: this._annotationStrokePaint(style, [1, 1, 1, 1]); +ctx.lineWidth = Math.max(0, this._styleNumber(style, "stroke_width", 1.5)); +ctx.beginPath(); +if (symbol === "square") { +ctx.rect(x - r, y - r, r * 2, r * 2); +} else if (symbol === "diamond") { +ctx.moveTo(x, y - r); +ctx.lineTo(x + r, y); +ctx.lineTo(x, y + r); +ctx.lineTo(x - r, y); +ctx.closePath(); +} else if (symbol === "cross") { +ctx.moveTo(x - r, y); +ctx.lineTo(x + r, y); +ctx.moveTo(x, y - r); +ctx.lineTo(x, y + r); +ctx.stroke(); +ctx.restore(); +return; +} else { +ctx.arc(x, y, r, 0, Math.PI * 2); +} +ctx.fill(); +if (ctx.lineWidth > 0) ctx.stroke(); +ctx.restore(); +}, +_drawArrowLine(ctx, x0, y0, x1, y1, style) { +if (![x0, y0, x1, y1].every(Number.isFinite)) return; +const angle = Math.atan2(y1 - y0, x1 - x0); +const head = Math.max(7, this._styleNumber(style, "head_size", 8)); +ctx.save(); +ctx.globalAlpha = this._styleNumber(style, "opacity", 1); +ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); +ctx.fillStyle = ctx.strokeStyle; +ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); +ctx.setLineDash(Array.isArray(style.dash) ? style.dash : +(typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); +ctx.beginPath(); +ctx.moveTo(x0, y0); +ctx.lineTo(x1, y1); +ctx.stroke(); +ctx.beginPath(); +ctx.moveTo(x1, y1); +ctx.lineTo( +x1 - head * Math.cos(angle - Math.PI / 6), +y1 - head * Math.sin(angle - Math.PI / 6) +); +ctx.lineTo( +x1 - head * Math.cos(angle + Math.PI / 6), +y1 - head * Math.sin(angle + Math.PI / 6) +); +ctx.closePath(); +ctx.fill(); +ctx.restore(); +}, +_drawAnnotationShapes(ctx) { +const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; +if (!annotations.length) return; +const p = this.plot; +ctx.save(); +ctx.beginPath(); +ctx.rect(p.x, p.y, p.w, p.h); +ctx.clip(); +for (const ann of annotations) { +const style = ann && typeof ann.style === "object" ? ann.style : {}; +if (ann.kind === "band") { +const vertical = ann.axis === "x"; +const a = vertical ? this._dataPxX(Number(ann.start)) : this._dataPxY(Number(ann.start)); +const b = vertical ? this._dataPxX(Number(ann.end)) : this._dataPxY(Number(ann.end)); +if (!Number.isFinite(a) || !Number.isFinite(b)) continue; +const lo = Math.max(vertical ? p.x : p.y, Math.min(a, b)); +const hi = Math.min(vertical ? p.x + p.w : p.y + p.h, Math.max(a, b)); +if (hi <= lo) continue; +ctx.save(); +ctx.globalAlpha = this._styleNumber(style, "opacity", 0.14); +ctx.fillStyle = this._annotationPaint(style, [0.39, 0.45, 0.55, 1]); +const start = Math.max(0, Math.min(1, Number(style.span_start) || 0)); +const rawEnd = style.span_end === undefined ? 1 : Number(style.span_end); +const end = Math.max(start, Math.min(1, Number.isFinite(rawEnd) ? rawEnd : 1)); +if (vertical) ctx.fillRect(lo, p.y + (1 - end) * p.h, hi - lo, (end - start) * p.h); +else ctx.fillRect(p.x + start * p.w, lo, (end - start) * p.w, hi - lo); +ctx.restore(); +} else if (ann.kind === "rule") { +const vertical = ann.axis === "x"; +const pos = vertical ? this._dataPxX(Number(ann.value)) : this._dataPxY(Number(ann.value)); +if (!Number.isFinite(pos)) continue; +if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) continue; +if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) continue; +const crisp = Math.round(pos) + 0.5; +ctx.save(); +ctx.globalAlpha = this._styleNumber(style, "opacity", 1); +ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); +ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); +ctx.setLineDash(Array.isArray(style.dash) ? style.dash : +(typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); +ctx.beginPath(); +const start = Math.max(0, Math.min(1, Number(style.span_start) || 0)); +const rawEnd = style.span_end === undefined ? 1 : Number(style.span_end); +const end = Math.max(start, Math.min(1, Number.isFinite(rawEnd) ? rawEnd : 1)); +if (vertical) { +ctx.moveTo(crisp, p.y + (1 - end) * p.h); +ctx.lineTo(crisp, p.y + (1 - start) * p.h); +} else { +ctx.moveTo(p.x + start * p.w, crisp); +ctx.lineTo(p.x + end * p.w, crisp); +} +ctx.stroke(); +ctx.restore(); +} else if (ann.kind === "arrow") { +this._drawArrowLine( +ctx, +this._dataPxX(Number(ann.x0)), +this._dataPxY(Number(ann.y0)), +this._dataPxX(Number(ann.x1)), +this._dataPxY(Number(ann.y1)), +style +); +} else if (ann.kind === "callout") { +const px = this._dataPxX(Number(ann.x)); +const py = this._dataPxY(Number(ann.y)); +const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; +const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; +this._drawArrowLine(ctx, px + dx, py + dy, px, py, style); +} else if (ann.kind === "marker") { +this._drawAnnotationMarker( +ctx, +this._dataPxX(Number(ann.x)), +this._dataPxY(Number(ann.y)), +style, +ann +); +} +} +ctx.restore(); +}, +_drawAnnotationLabels(updateLabels) { +if (!updateLabels) return; +const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; +if (!annotations.length) return; +const p = this.plot; +for (const ann of annotations) { +const text = typeof ann.text === "string" ? ann.text : ""; +if (!text) continue; +const style = ann && typeof ann.style === "object" ? ann.style : {}; +let px = null; +let py = null; +if (ann.kind === "text") { +if (style.coordinate_space === "axes_fraction") { +px = p.x + Number(ann.x) * p.w; +py = p.y + (1 - Number(ann.y)) * p.h; +} else if (style.coordinate_space === "figure_fraction") { +px = Number(ann.x) * this.size.w; +py = (1 - Number(ann.y)) * this.size.h; +} else if (style.coordinate_space === "yaxis_transform") { +px = p.x + Number(ann.x) * p.w; +py = this._dataPxY(Number(ann.y)); +} else if (style.coordinate_space === "xaxis_transform") { +px = this._dataPxX(Number(ann.x)); +py = p.y + (1 - Number(ann.y)) * p.h; +} else { +px = this._dataPxX(Number(ann.x)); +py = this._dataPxY(Number(ann.y)); +} +} else if (ann.kind === "rule") { +if (ann.axis === "x") { +px = this._dataPxX(Number(ann.value)); +py = p.y + 6; +} else { +px = p.x + p.w - 6; +py = this._dataPxY(Number(ann.value)); +} +} else if (ann.kind === "band") { +if (ann.axis === "x") { +px = (this._dataPxX(Number(ann.start)) + this._dataPxX(Number(ann.end))) / 2; +py = p.y + 6; +} else { +px = p.x + p.w - 6; +py = (this._dataPxY(Number(ann.start)) + this._dataPxY(Number(ann.end))) / 2; +} +} else if (ann.kind === "arrow") { +px = (this._dataPxX(Number(ann.x0)) + this._dataPxX(Number(ann.x1))) / 2; +py = (this._dataPxY(Number(ann.y0)) + this._dataPxY(Number(ann.y1))) / 2; +} else if (ann.kind === "callout") { +px = this._dataPxX(Number(ann.x)); +py = this._dataPxY(Number(ann.y)); +} else if (ann.kind === "marker") { +px = this._dataPxX(Number(ann.x)); +py = this._dataPxY(Number(ann.y)); +} +if (!Number.isFinite(px) || !Number.isFinite(py)) continue; +if (px < p.x - 24 || px > p.x + p.w + 24 || py < p.y - 24 || py > p.y + p.h + 24) { +continue; +} +const d = document.createElement("div"); +d.textContent = text; +const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; +const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; +const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0"; +d.style.cssText = +`position:absolute;left:${px + dx}px;top:${py + dy}px;` + +`transform:translate(${anchor},0);pointer-events:none;` + +`white-space:pre-line;text-align:center;`; +this._applySlot(d, "annotation_label"); +this._applyClass(d, ann.class_name); +this._applyStyle(d, style); +if (style && (style.label_color || style.color)) { +d.style.color = this._annotationLabelPaint(style, this.theme.label); +} +this.labels.appendChild(d); +} +}, +}); +Object.assign(ChartView.prototype, { +_showTooltip(hit, clientX, clientY) { +const row = this._localRow(hit); +this._lastRow = row; +this._renderTooltip(row, clientX, clientY); +if (this._interactionFlag("hover")) { +this._dispatchChartEvent("hover", { +row, +trace: hit.trace, +index: hit.index, +view: this._eventView("hover"), +}); +} +if (this.comm) { +this._pickSeq = (this._pickSeq || 0) + 1; +const req = { type: "pick", seq: this._pickSeq, trace: hit.trace, index: hit.index }; +const hg = hit.g; +if (hg && hg.tier === "density" && hg.drill && hg.drill.seq !== undefined) { +req.drill_seq = hg.drill.seq; +} +this.comm.send(req); +} +}, +_localRow(hit) { +const g = hit.g; +const cpu = g._cpu; +const row = { trace: g.trace.id, index: hit.index }; +if (hit.heatmap && g.heatmap && g._cpuHeatmap) { +const h = g.heatmap; +const { row: heatRow, col } = hit.heatmap; +const rawX = h.xRange[0] + (col + 0.5) * ((h.xRange[1] - h.xRange[0]) / h.w); +const rawY = h.yRange[0] + (heatRow + 0.5) * ((h.yRange[1] - h.yRange[0]) / h.h); +const [x, xKind] = this._sourceDisplayValue(g, "x", rawX, "float"); +const [y, yKind] = this._sourceDisplayValue(g, "y", rawY, "float"); +row.x = x; +row.y = y; +if (xKind !== undefined) row.x_kind = xKind; +if (yKind !== undefined) row.y_kind = yKind; +const norm = g._cpuHeatmap.grid[hit.index]; +row.color_value = this._denormalizeUnit(norm, g.trace.color && g.trace.color.domain); +} else if (g._cpuRect) { +const r = g._cpuRect; +const x0 = this._decodeValue(r.x0, r.x0Meta, hit.index); +const x1 = this._decodeValue(r.x1, r.x1Meta, hit.index); +const y0 = this._decodeValue(r.y0, r.y0Meta, hit.index); +const y1 = this._decodeValue(r.y1, r.y1Meta, hit.index); +row.x = x0 + (x1 - x0) / 2; +row.y = y1; +row.x_kind = r.x0Meta.kind; +row.y_kind = r.y1Meta.kind; +} else if (cpu) { +const xMeta = cpu.xMeta || g.xMeta; +const yMeta = cpu.yMeta || g.yMeta; +row.x = this._decodeValue(cpu.x, xMeta, hit.index); +row.y = this._decodeValue(cpu.y, yMeta, hit.index); +row.x_kind = xMeta && xMeta.kind; +row.y_kind = yMeta && yMeta.kind; +const color = g.trace.color; +if (cpu.color && color) { +if (color.mode === "categorical" && Array.isArray(color.categories)) { +const code = Math.round(cpu.color[hit.index]); +if (code >= 0 && code < color.categories.length) { +row.color_category = String(color.categories[code]); +} +} else if (color.mode === "continuous") { +row.color_value = this._denormalizeUnit(cpu.color[hit.index], color.domain); +} +} +const size = g.trace.size; +if (cpu.size && size && size.mode === "continuous") { +row.size_value = this._denormalizeUnit(cpu.size[hit.index], size.domain); +} +} +this._applySharedTooltipFields(row); +return row; +}, +_sourceDisplayValue(g, channel, value, kind) { +const axis = channel === "x" ? this._axis(g && g.xAxis) : this._axis(g && g.yAxis); +if (channel === "x" && axis.kind === "category") { +return [fmtCategory(value, axis.categories || []), undefined]; +} +if (channel === "y" && axis.kind === "category") { +return [fmtCategory(value, axis.categories || []), undefined]; +} +return [value, kind]; +}, +_sourceValue(g, source, index) { +if (!g || index < 0) return [undefined, undefined]; +const channel = source.channel; +if (channel === "x" || channel === "y") { +const cpu = g._cpu; +if (!cpu || !cpu[channel]) return [undefined, undefined]; +const meta = channel === "x" ? (cpu.xMeta || g.xMeta) : (cpu.yMeta || g.yMeta); +const value = this._decodeValue(cpu[channel], meta, index); +if (!Number.isFinite(value)) return [undefined, undefined]; +return this._sourceDisplayValue(g, channel, value, meta && meta.kind); +} +if (channel === "color_value") { +if (g._cpuHeatmap && g._cpuHeatmap.grid && g.trace.color) { +return [this._denormalizeUnit(g._cpuHeatmap.grid[index], g.trace.color.domain), undefined]; +} +if (g._cpu && g._cpu.color && g.trace.color) { +return [this._denormalizeUnit(g._cpu.color[index], g.trace.color.domain), undefined]; +} +} +if (channel === "color_category" && g._cpu && g._cpu.color && g.trace.color) { +const code = Math.round(g._cpu.color[index]); +const categories = g.trace.color.categories || []; +if (code >= 0 && code < categories.length) return [String(categories[code]), undefined]; +} +if (channel === "size_value" && g._cpu && g._cpu.size && g.trace.size) { +return [this._denormalizeUnit(g._cpu.size[index], g.trace.size.domain), undefined]; +} +return [undefined, undefined]; +}, +_applySharedTooltipFields(row) { +const sources = this.spec.tooltip && this.spec.tooltip.sources; +if (!sources || typeof sources !== "object" || row.x === undefined) return; +for (const [field, entries] of Object.entries(sources)) { +if (!Array.isArray(entries) || row[field] !== undefined) continue; +const source = entries.find((entry) => entry.trace === row.trace) || entries[0]; +if (!source || !Number.isFinite(Number(source.trace))) continue; +const g = this.gpuTraces.find((trace) => trace.trace.id === source.trace); +if (!g) continue; +let idx = Number.isInteger(row.index) && source.trace === row.trace ? row.index : -1; +if ( +!g._cpuHeatmap && +(idx < 0 || !g._cpu || !g._cpu.x || idx >= g._cpu.x.length) +) { +idx = this._nearestCpuIndex(g, row.x); +} +const [value, kind] = this._sourceValue(g, source, idx); +if (value === undefined) continue; +row[field] = value; +if (kind !== undefined) row[`${field}_kind`] = kind; +} +}, +_denormalizeUnit(value, domain) { +const v = Number(value); +if (!Number.isFinite(v)) return v; +if (!Array.isArray(domain) || domain.length < 2) return v; +const lo = Number(domain[0]); +const hi = Number(domain[1]); +if (!Number.isFinite(lo) || !Number.isFinite(hi)) return v; +return lo + v * (hi - lo); +}, +_defaultTooltipLines(row) { +const lines = []; +if (row.x !== undefined) lines.push(`x: ${fmtValue(row.x, row.x_kind)}`); +if (row.y !== undefined) lines.push(`y: ${fmtValue(row.y, row.y_kind)}`); +if (row.color_value !== undefined) lines.push(`color: ${fmtValue(row.color_value)}`); +if (row.color_category !== undefined) lines.push(`${row.color_category}`); +if (row.size_value !== undefined) lines.push(`size: ${fmtValue(row.size_value)}`); +if (!lines.length) lines.push(`#${row.index}`); +return lines; +}, +_tooltipLookup(row, field) { +const aliases = (this.spec.tooltip && this.spec.tooltip.aliases) || {}; +const key = row[field] !== undefined ? field : aliases[field]; +if (!key || row[key] === undefined) return [undefined, undefined]; +return [row[key], row[`${key}_kind`]]; +}, +_formatTooltipValue(value, kind, format) { +const formatted = fmtNumberSpec(value, format); +if (formatted !== null) return formatted; +return fmtValue(value, kind); +}, +_tooltipLines(row) { +const tooltip = this.spec.tooltip || {}; +if (!tooltip.title && !Array.isArray(tooltip.fields)) return this._defaultTooltipLines(row); +const formats = tooltip.format || {}; +const lines = []; +if (typeof tooltip.title === "string") { +const title = tooltip.title.replace(/\{([^}]+)\}/g, (_, field) => { +const [value, kind] = this._tooltipLookup(row, field); +return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); +}); +if (title) lines.push(title); +} +if (Array.isArray(tooltip.fields)) { +for (const field of tooltip.fields) { +if (typeof field !== "string") continue; +const [value, kind] = this._tooltipLookup(row, field); +if (value === undefined) continue; +lines.push(`${field}: ${this._formatTooltipValue(value, kind, formats[field])}`); +} +} +return lines.length ? lines : this._defaultTooltipLines(row); +}, +_renderTooltip(row, clientX, clientY) { +if (!row || this.spec.show_tooltip === false) { +this.tooltip.style.display = "none"; +return; +} +const rect = this.root.getBoundingClientRect(); +const lx = clientX - rect.left; +const ly = clientY - rect.top; +const lines = this._tooltipLines(row); +this.tooltip.textContent = ""; +lines.forEach((ln, i) => { +if (i) this.tooltip.appendChild(document.createElement("br")); +this.tooltip.appendChild(document.createTextNode(ln)); +}); +this.tooltip.style.display = "block"; +const tw = this.tooltip.offsetWidth; +this.tooltip.style.left = Math.min(lx + 12, this.size.w - tw - 4) + "px"; +this.tooltip.style.top = ly + 12 + "px"; +}, +}); +Object.assign(ChartView.prototype, { +_initInteraction() { +const c = this.canvas; +let drag = null; +let band = null; +this.selRect = document.createElement("div"); +this.selRect.style.cssText = "position:absolute;display:none;pointer-events:none;z-index:4;"; +this._applySlot(this.selRect, "selection"); +this.root.appendChild(this.selRect); +if (this._interactionFlag("crosshair")) { +this.crosshairX = document.createElement("div"); +this.crosshairX.style.cssText = +"position:absolute;display:none;pointer-events:none;z-index:3;width:1px;"; +this._applySlot(this.crosshairX, "crosshair_x"); +this.root.appendChild(this.crosshairX); +this.crosshairY = document.createElement("div"); +this.crosshairY.style.cssText = +"position:absolute;display:none;pointer-events:none;z-index:3;height:1px;"; +this._applySlot(this.crosshairY, "crosshair_y"); +this.root.appendChild(this.crosshairY); +} +const dataAt = (clientX, clientY) => { +const r = c.getBoundingClientRect(); +return this._dataFromCanvas(clientX - r.left, clientY - r.top); +}; +this._listen(c, "pointerdown", (e) => { +this._cancelViewAnimation(); +const canBrush = this._interactionFlag("brush", true) && this._interactionFlag("select", true); +const mode = e.shiftKey && canBrush && this._pickable ? "select" +: this.dragMode === "zoom" ? "zoom" : null; +if (mode) { +band = { mode, sx: e.clientX, sy: e.clientY, d0: dataAt(e.clientX, e.clientY) }; +c.setPointerCapture(e.pointerId); +this.tooltip.style.display = "none"; +return; +} +drag = { px: e.clientX, py: e.clientY, view: { ...this.view }, moved: false }; +c.setPointerCapture(e.pointerId); +this.tooltip.style.display = "none"; +}); +this._listen(c, "pointermove", (e) => { +if (band) { this._updateBand(band, e); return; } +if (drag) { +drag.moved = true; +const { x0, x1, y0, y1 } = drag.view; +const xa = this._axis("x"); +const ya = this._axis("y"); +const cx0 = this._axisCoord(xa, x0), cx1 = this._axisCoord(xa, x1); +const cy0 = this._axisCoord(ya, y0), cy1 = this._axisCoord(ya, y1); +const dx = ((e.clientX - drag.px) / this.plot.w) * (cx1 - cx0); +const dy = ((e.clientY - drag.py) / this.plot.h) * (cy1 - cy0); +this.view = { +x0: this._axisValue(xa, cx0 - dx), +x1: this._axisValue(xa, cx1 - dx), +y0: this._axisValue(ya, cy0 + dy), +y1: this._axisValue(ya, cy1 + dy), +}; +this.draw(); +this._scheduleViewRequest(); +this._emitViewChange("pan"); +return; +} +this._updateCrosshair(e); +this._hover(e); +}); +const end = (e) => { +if (band) { +this.selRect.style.display = "none"; +const d1 = dataAt(e.clientX, e.clientY); +const moved = Math.abs(e.clientX - band.sx) > 3 || Math.abs(e.clientY - band.sy) > 3; +if (moved) { +if (band.mode === "zoom") this._zoomToBox(band.d0, d1, true); +else this._sendSelect(band.d0, d1); +this._ignoreNextClick = true; +} +band = null; +return; +} +if (drag && drag.moved) this._ignoreNextClick = true; +if (drag && !drag.moved) this.tooltip.style.display = "none"; +drag = null; +}; +this._listen(c, "pointerup", end); +this._listen(c, "pointercancel", () => { this.selRect.style.display = "none"; band = null; drag = null; }); +this._listen(c, "pointerleave", () => { +const hadHover = this._hoverId !== -1; +this._hoverId = -1; +this._hoverTarget = null; +this.tooltip.style.display = "none"; +this._hideCrosshair(); +if (this._interactionFlag("hover")) { +this._dispatchChartEvent("leave", { view: this._eventView("leave") }); +} +if (hadHover) this._drawKeepPick(); +}); +this._listen(c, "click", (e) => this._click(e)); +this._listen(c, "wheel", (e) => { +e.preventDefault(); +const f = Math.pow(1.0015, e.deltaY); +const r = c.getBoundingClientRect(); +const fx = (e.clientX - r.left) / r.width; +const fy = 1 - (e.clientY - r.top) / r.height; +this._queueWheelZoom(f, fx, fy); +}, { passive: false }); +this._listen(c, "dblclick", () => { +this._clearSelection(); +this._setView(this.view0, { animate: true }); +}); +}, +_updateCrosshair(e) { +if (!this.crosshairX || !this.crosshairY) return; +const rect = this.canvas.getBoundingClientRect(); +const rootRect = this.root.getBoundingClientRect(); +const x = e.clientX - rect.left; +const y = e.clientY - rect.top; +if (x < 0 || x > rect.width || y < 0 || y > rect.height) { +this._hideCrosshair(); +return; +} +const left = e.clientX - rootRect.left; +const top = e.clientY - rootRect.top; +this.crosshairX.style.display = "block"; +this.crosshairX.style.left = left + "px"; +this.crosshairX.style.top = this.plot.y + "px"; +this.crosshairX.style.height = this.plot.h + "px"; +this.crosshairY.style.display = "block"; +this.crosshairY.style.left = this.plot.x + "px"; +this.crosshairY.style.top = top + "px"; +this.crosshairY.style.width = this.plot.w + "px"; +}, +_hideCrosshair() { +if (this.crosshairX) this.crosshairX.style.display = "none"; +if (this.crosshairY) this.crosshairY.style.display = "none"; +}, +_click(e) { +if (this._ignoreNextClick) { +this._ignoreNextClick = false; +return; +} +if (!this._interactionFlag("click")) return; +const rect = this.canvas.getBoundingClientRect(); +const cssX = e.clientX - rect.left; +const cssY = e.clientY - rect.top; +const [x, y] = this._dataFromCanvas(cssX, cssY); +const hit = this._pickAt(cssX, cssY) || this._hoverAt(cssX, cssY); +const detail = { +x, +y, +view: this._eventView("click"), +row: hit && this._localRow ? this._localRow(hit) : null, +trace: hit ? hit.trace : null, +index: hit ? hit.index : null, +}; +this._dispatchChartEvent("click", detail); +if (hit && this.comm) { +const msg = { type: "click", trace: hit.trace, index: hit.index }; +const g = hit.g; +if (g && g.tier === "density" && g.drill && g.drill.seq !== undefined) { +msg.drill_seq = g.drill.seq; +} +this.comm.send(msg); +} +}, +_updateBand(band, e) { +const rect = this.canvas.getBoundingClientRect(); +const rootRect = this.root.getBoundingClientRect(); +const x = Math.min(band.sx, e.clientX) - rootRect.left; +const y = Math.min(band.sy, e.clientY) - rootRect.top; +const w = Math.abs(e.clientX - band.sx); +const h = Math.abs(e.clientY - band.sy); +const px = this.plot.x, py = this.plot.y; +const x2 = Math.min(x + w, px + this.plot.w), y2 = Math.min(y + h, py + this.plot.h); +const cx = Math.max(x, px), cy = Math.max(y, py); +this.selRect.dataset.fcBand = band.mode === "zoom" ? "zoom" : "select"; +this.selRect.style.display = "block"; +this.selRect.style.left = cx + "px"; +this.selRect.style.top = cy + "px"; +this.selRect.style.width = Math.max(0, x2 - cx) + "px"; +this.selRect.style.height = Math.max(0, y2 - cy) + "px"; +void rect; +}, +_sendSelect(d0, d1) { +const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); +const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); +const range = { x0, x1, y0, y1 }; +this._dispatchChartEvent("brush", { range, view: this._eventView("brush") }); +if (this.comm) { +this.comm.send({ type: "select", x0, x1, y0, y1 }); +} else { +this._selectLocal(x0, x1, y0, y1); +} +}, +_selectLocal(x0, x1, y0, y1) { +let total = 0; +for (const g of this.gpuTraces) { +if (!g._cpu || g.tier === "density") continue; +const cx = g._cpu.x, cy = g._cpu.y; +const xMeta = g._cpu.xMeta || g.xMeta; +const yMeta = g._cpu.yMeta || g.yMeta; +const ox = xMeta.offset, sx = xMeta.scale || 1; +const oy = yMeta.offset, sy = yMeta.scale || 1; +const mask = new Float32Array(g.n); +let cnt = 0; +for (let i = 0; i < g.n; i++) { +const dx = cx[i] / sx + ox, dy = cy[i] / sy + oy; +if (dx >= x0 && dx <= x1 && dy >= y0 && dy <= y1) { mask[i] = 1; cnt++; } +} +this._applySelMask(g, mask); +total += cnt; +} +this._selectionCount = total; +this.draw(); +this._dispatchChartEvent("select", { +total, +range: { x0, x1, y0, y1 }, +view: this._eventView("select"), +}); +}, +_applySelMask(g, maskF32) { +const gl = this.gl; +if (!g.selBuf) g.selBuf = gl.createBuffer(); +gl.bindBuffer(gl.ARRAY_BUFFER, g.selBuf); +gl.bufferData(gl.ARRAY_BUFFER, maskF32, gl.STATIC_DRAW); +g.selActive = true; +}, +_clearSelection() { +for (const g of this.gpuTraces) { +g.selActive = false; +if (g.drill) g.drill.selActive = false; +} +this._selectionCount = 0; +if (this._interactionFlag("select", true)) { +if (this.comm) this.comm.send({ type: "select_clear" }); +this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); +} +}, +_buildModebar(root) { +if (this.spec.show_modebar === false) return; +const bar = document.createElement("div"); +bar.style.cssText = +`position:absolute;top:${this.plot.y + 4}px;left:${this.plot.x + 4}px;z-index:6;` + +"display:flex;opacity:.72;transition:opacity .15s;"; +this._applySlot(bar, "modebar"); +this._listen(root, "pointerenter", () => { bar.style.opacity = "1"; }); +this._listen(root, "pointerleave", () => { bar.style.opacity = ".72"; }); +this._modebar = bar; +this._modeBtns = {}; +const mk = (name, title, onClick, toggles) => { +const b = document.createElement("button"); +b.type = "button"; +b.title = title; +b.innerHTML = this._icon(name); +b.style.cssText = +"display:flex;align-items:center;justify-content:center;pointer-events:auto;"; +this._applySlot(b, "modebar_button"); +this._listen(b, "pointerdown", (e) => e.stopPropagation()); +this._listen(b, "click", (e) => { e.stopPropagation(); onClick(); }); +bar.appendChild(b); +if (toggles) this._modeBtns[toggles] = b; +return b; +}; +mk("zoomin", "Zoom in", () => this._zoomBy(0.5, true)); +mk("zoomout", "Zoom out", () => this._zoomBy(2, true)); +mk("pan", "Pan", () => this._setDragMode("pan"), "pan"); +mk("zoom", "Box zoom", () => this._setDragMode("zoom"), "zoom"); +mk("reset", "Reset view", () => { +this._clearSelection(); +this._setView(this.view0, { animate: true }); +}); +root.appendChild(bar); +this._setDragMode(this.dragMode); +}, +_setDragMode(mode) { +this.dragMode = mode; +if (this.canvas) this.canvas.dataset.fcDragmode = mode; +for (const [name, btn] of Object.entries(this._modeBtns || {})) { +btn.classList.toggle("fc-active", name === mode); +} +}, +_prefersReducedMotion() { +return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; +}, +_cancelViewAnimation() { +if (this._animRaf) cancelAnimationFrame(this._animRaf); +this._animRaf = null; +this._viewAnim = null; +}, +_setView(next, opts = {}) { +if (this._destroyed) return; +const target = { x0: next.x0, x1: next.x1, y0: next.y0, y1: next.y1 }; +const animate = opts.animate === true && !this._prefersReducedMotion(); +const duration = opts.duration || 180; +if (!animate || duration <= 0) { +this._cancelViewAnimation(); +this.view = target; +this.draw(); +if (opts.request !== false) this._scheduleViewRequest(); +this._emitViewChange(opts.source || "view", { broadcast: opts.broadcast }); +return; +} +clearTimeout(this._viewTimer); +this.seq += 1; +const request = opts.request !== false; +const requestDelay = opts.requestDelay ?? Math.min(55, Math.max(24, duration * 0.35)); +const requestMaxWait = opts.requestMaxWait ?? 130; +if (request) { +this._scheduleViewRequest(target, { seq: this.seq, delay: requestDelay, maxWait: requestMaxWait }); +} +const now = this._now(); +const tau = Math.max(18, duration / 5); +if (this._viewAnim) { +this._viewAnim.target = target; +this._viewAnim.tau = tau; +return; +} +this._viewAnim = { +target, +last: now, +tau, +}; +const lerp = (a, b, t) => a + (b - a) * t; +const span = (v) => Math.max(Math.abs(v.x1 - v.x0), Math.abs(v.y1 - v.y0), 1e-12); +const closeEnough = (a, b) => { +const tol = span(b) * 1e-4; +return Math.max( +Math.abs(a.x0 - b.x0), Math.abs(a.x1 - b.x1), +Math.abs(a.y0 - b.y0), Math.abs(a.y1 - b.y1)) <= tol; +}; +const step = (nowFrame) => { +if (this._destroyed) { this._animRaf = null; return; } +const anim = this._viewAnim; +if (!anim) { this._animRaf = null; return; } +const dt = Math.max(0, Math.min(64, nowFrame - anim.last)); +anim.last = nowFrame; +const k = 1 - Math.exp(-dt / anim.tau); +const t = closeEnough(this.view, anim.target) ? 1 : k; +this.view = { +x0: lerp(this.view.x0, anim.target.x0, t), +x1: lerp(this.view.x1, anim.target.x1, t), +y0: lerp(this.view.y0, anim.target.y0, t), +y1: lerp(this.view.y1, anim.target.y1, t), +}; +if (t < 1) { +this.draw(); +this._animRaf = requestAnimationFrame(step); +} else { +this._animRaf = null; +this._viewAnim = null; +this.view = anim.target; +this._lastLabelDraw = null; +this.draw(); +this._emitViewChange(opts.source || "view", { broadcast: opts.broadcast }); +} +}; +this._animRaf = requestAnimationFrame(step); +}, +_zoomBy(f, animate = false) { +const base = this._viewAnim ? this._viewAnim.target : this.view; +const { x0, x1, y0, y1 } = base; +const xr = this._zoomAxisRange("x", x0, x1, f, 0.5); +const yr = this._zoomAxisRange("y", y0, y1, f, 0.5); +if (!xr || !yr) return; +this._setView({ x0: xr[0], x1: xr[1], y0: yr[0], y1: yr[1] }, { animate }); +}, +_zoomAxisRange(axisId, lo, hi, f, anchorFrac) { +const axis = this._axis(axisId); +const c0 = this._axisCoord(axis, lo); +const c1 = this._axisCoord(axis, hi); +if (![c0, c1].every(Number.isFinite) || c0 === c1) return null; +const ca = c0 + anchorFrac * (c1 - c0); +if (f < 1) { +const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12; +if (Math.abs((c1 - c0) * f) < minSpan) return null; +} +return [ +this._axisValue(axis, ca - (ca - c0) * f), +this._axisValue(axis, ca + (c1 - ca) * f), +]; +}, +_zoomAt(f, fx, fy, animate = false, duration = 120) { +const base = this._viewAnim ? this._viewAnim.target : this.view; +const { x0, x1, y0, y1 } = base; +const xr = this._zoomAxisRange("x", x0, x1, f, fx); +const yr = this._zoomAxisRange("y", y0, y1, f, fy); +if (!xr || !yr) return; +this._setView({ x0: xr[0], x1: xr[1], y0: yr[0], y1: yr[1] }, { animate, duration }); +}, +_queueWheelZoom(factor, fx, fy) { +if (!Number.isFinite(factor) || factor <= 0) return; +if (!this._pendingWheelZoom) { +this._pendingWheelZoom = { factor: 1, fx, fy }; +} +this._pendingWheelZoom.factor *= factor; +this._pendingWheelZoom.fx = fx; +this._pendingWheelZoom.fy = fy; +if (this._wheelZoomRaf) return; +this._wheelZoomRaf = requestAnimationFrame(() => { +this._wheelZoomRaf = null; +const pending = this._pendingWheelZoom; +this._pendingWheelZoom = null; +if (!pending || this._destroyed) return; +this._zoomAt(pending.factor, pending.fx, pending.fy, false); +}); +}, +_zoomToBox(d0, d1, animate = false) { +const xa = this._axis("x"); +const ya = this._axis("y"); +const xlo = Math.min(d0[0], d1[0]), xhi = Math.max(d0[0], d1[0]); +const ylo = Math.min(d0[1], d1[1]), yhi = Math.max(d0[1], d1[1]); +const cx0 = this._axisCoord(xa, xlo), cx1 = this._axisCoord(xa, xhi); +const cy0 = this._axisCoord(ya, ylo), cy1 = this._axisCoord(ya, yhi); +if (![cx0, cx1, cy0, cy1].every(Number.isFinite)) return; +const minSpanX = Math.max(Math.abs(cx0), Math.abs(cx1), 1e-30) * 1e-12; +const minSpanY = Math.max(Math.abs(cy0), Math.abs(cy1), 1e-30) * 1e-12; +if (Math.abs(cx1 - cx0) < minSpanX || Math.abs(cy1 - cy0) < minSpanY) return; +const xReversed = this.view.x1 < this.view.x0; +const yReversed = this.view.y1 < this.view.y0; +const x0 = xReversed ? xhi : xlo; +const x1 = xReversed ? xlo : xhi; +const y0 = yReversed ? yhi : ylo; +const y1 = yReversed ? ylo : yhi; +this._setView({ x0, x1, y0, y1 }, { animate }); +}, +_icon(name) { +const svg = (body) => +`${body}`; +switch (name) { +case "zoomin": +return svg('' + +''); +case "zoomout": +return svg('' + +''); +case "pan": +return svg('' + +'' + +''); +case "zoom": +return svg(''); +case "reset": +return svg(''); +default: +return svg(""); +} +}, +}); +Object.assign(ChartView.prototype, { +_scheduleViewRequest(viewOverride = this.view, opts = {}) { +if (this._destroyed || this._glLost) return; +if (!this.comm) { +this._scheduleSampleRebin(viewOverride, opts); +return; +} +const needsDecimated = this.spec.traces.some((t) => t.tier === "decimated"); +const needsDensity = this.gpuTraces.some((g) => g.tier === "density"); +if (!needsDecimated && !needsDensity) return; +const seq = opts.seq ?? ++this.seq; +const view = { ...viewOverride }; +const plotW = Math.round(this.plot.w); +const plotH = Math.round(this.plot.h); +if (needsDensity) { +const now = this._now(); +for (const g of this.gpuTraces) { +if (g.tier !== "density") continue; +g._lodPendingView = view; +g._lodPendingSeq = seq; +g._lodPendingAt = now; +} +} +let delay = opts.delay ?? 120; +if (opts.maxWait !== undefined && opts.maxWait !== null) { +const now = this._now(); +if (this._viewRequestBurstStart === undefined || this._viewRequestBurstStart === null) { +this._viewRequestBurstStart = now; +} +const remaining = opts.maxWait - (now - this._viewRequestBurstStart); +delay = remaining <= 0 ? 0 : Math.min(delay, remaining); +} else { +this._viewRequestBurstStart = null; +} +clearTimeout(this._viewTimer); +const send = () => { +if (this._destroyed) return; +this._viewRequestBurstStart = null; +if (seq !== this.seq) return; +if (needsDecimated) { +this.comm.send({ +type: "view", seq, +x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), px: plotW, +}); +} +if (needsDensity) { +for (const g of this.gpuTraces) { +if (g.tier !== "density") continue; +this.comm.send({ +type: "density_view", seq, trace: g.trace.id, +x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), +y0: Math.min(view.y0, view.y1), y1: Math.max(view.y0, view.y1), +w: plotW, h: plotH, +}); +} +} +}; +if (delay <= 0) { +send(); +} else { +this._viewTimer = setTimeout(send, delay); +} +return seq; +}, +_scheduleSampleRebin(viewOverride = this.view, opts = {}) { +if (this._destroyed || this._glLost || this._sampleRebinDisabled) return; +const targets = (this.gpuTraces || []).filter( +(g) => g.tier === "density" && g.sampleOverlay && g.sampleOverlay._cpu +); +if (!targets.length) return; +const seq = opts.seq ?? ++this.seq; +const view = { ...viewOverride }; +clearTimeout(this._rebinTimer); +this._rebinTimer = setTimeout(() => { +if (this._destroyed || seq !== this.seq) return; +for (const g of targets) this._requestSampleRebin(g, view, seq); +}, opts.delay ?? 120); +}, +_requestSampleRebin(g, view, seq) { +if (!g._homeDensity) g._homeDensity = g.density; +const v0 = this.view0; +const ex = Math.max(Math.abs(v0.x1 - v0.x0), 1e-300) * 1e-9; +const ey = Math.max(Math.abs(v0.y1 - v0.y0), 1e-300) * 1e-9; +const atHome = +Math.min(view.x0, view.x1) <= v0.x0 + ex && Math.max(view.x0, view.x1) >= v0.x1 - ex && +Math.min(view.y0, view.y1) <= v0.y0 + ey && Math.max(view.y0, view.y1) >= v0.y1 - ey; +if (atHome) { +if (g.density !== g._homeDensity) { +const hd = g._homeDensity; +this._applySampleRebinGrid(g, { +...hd, +tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1), +}, false); +} +return; +} +if (this._sampleRebinDisabled) return; +if (!this._rebinWorker) { +this._rebinWorker = fcCreateRebinWorker(); +if (!this._rebinWorker) { +this._sampleRebinDisabled = true; +return; +} +this._rebinWorker.onmessage = (e) => this._onRebinResult(e.data); +this._rebinInit = new Set(); +} +if (!this._rebinInit.has(g.trace.id)) { +const cpu = g.sampleOverlay._cpu; +const n = Math.min(cpu.x.length, cpu.y.length); +const xs = new Float64Array(n); +const ys = new Float64Array(n); +for (let i = 0; i < n; i++) { +xs[i] = this._decodeValue(cpu.x, cpu.xMeta, i); +ys[i] = this._decodeValue(cpu.y, cpu.yMeta, i); +} +this._rebinWorker.postMessage( +{ type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer }, +[xs.buffer, ys.buffer] +); +this._rebinInit.add(g.trace.id); +} +this._rebinWorker.postMessage({ +type: "rebin", trace: g.trace.id, seq, +x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), +y0: Math.min(view.y0, view.y1), y1: Math.max(view.y0, view.y1), +w: Math.max(16, Math.min(2048, Math.round(this.plot.w))), +h: Math.max(16, Math.min(2048, Math.round(this.plot.h))), +}); +}, +_onRebinResult(msg) { +if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; +const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); +if (!g) return; +const grid = new Float32Array(msg.grid); +this._applySampleRebinGrid(g, { +w: msg.w, h: msg.h, max: msg.max, normMax: msg.max, +colormap: g.density.colormap, +xRange: [msg.x0, msg.x1], yRange: [msg.y0, msg.y1], +grid, +tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1), +lut: g.density.lut, +}, true); +}, +_applySampleRebinGrid(g, density, rebinned) { +g.prevDensity = g.density; +g._densityFadeStart = this._now(); +g.densityNormMax = density.normMax || density.max; +g.density = density; +g._sampleRebinned = !!rebinned; +lodRememberDensity(this, g, g.density); +this._refreshReductionBadges(); +this.draw(); +}, +_applyAppend(msg, buffers) { +const spec = msg.spec; +const blobRaw = buffers && buffers[0]; +if (!spec || !blobRaw || !spec.traces) return; +const blob = bytesToSpan(blobRaw); +const spanEps = (lo, hi) => Math.max(Math.abs(hi - lo), 1e-300) * 1e-9; +const ex = spanEps(this.view0.x0, this.view0.x1); +const ey = spanEps(this.view0.y0, this.view0.y1); +const atHome = +Math.abs(this.view.x0 - this.view0.x0) <= ex && Math.abs(this.view.x1 - this.view0.x1) <= ex && +Math.abs(this.view.y0 - this.view0.y0) <= ey && Math.abs(this.view.y1 - this.view0.y1) <= ey; +const pinnedRight = !atHome && Math.abs(this.view.x1 - this.view0.x1) <= ex; +this.spec = spec; +this.axes = this._normalizeAxes(spec); +this._payload = blob; +this.view0 = { +x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], +y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], +}; +if (atHome) { +this.view = { ...this.view0 }; +} else if (pinnedRight) { +const w = this.view.x1 - this.view.x0; +this.view = { ...this.view, x1: this.view0.x1, x0: this.view0.x1 - w }; +} +if (this._glLost || !this.gl) return; +const texSeen = new Set(); +for (const id of msg.affected || []) { +const i = this.gpuTraces.findIndex((g) => g.trace.id === id); +const ts = spec.traces.find((t) => t.id === id); +if (i < 0 || !ts) continue; +this._destroyTraceResources(this.gpuTraces[i], texSeen); +this.gpuTraces[i] = this._buildTrace(blob, ts); +} +this._pickable = this.gpuTraces.some( +(g) => markOf(g.trace.kind).pointPick && (g.tier !== "density" || g.drill)); +if (this._pickable && !this.pickFbo) this._initPickTarget(); +this._scheduleViewRequest(this.view, { delay: 0 }); +this.draw(); +}, +_onKernelMsg(msg, buffers) { +if (this._destroyed) return; +if (!msg) return; +if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return; +if (msg.type === "tier_update") { +if (msg.seq !== this.seq) return; +for (const upd of msg.traces) { +const g = this.gpuTraces.find((t) => t.trace.id === upd.id); +if (!g) continue; +const gl = this.gl; +const xArr = this._asF32(buffers[upd.x.buf]); +const yArr = this._asF32(buffers[upd.y.buf]); +const bArr = upd.base && g.baseBuf ? this._asF32(buffers[upd.base.buf]) : null; +let n = Math.min(upd.x.len, upd.y.len); +if (bArr) n = Math.min(n, upd.base.len); +const sm = this._smoothArrays(g.trace, xArr, yArr, bArr, n); +const src = sm || { x: xArr, y: yArr, n }; +const st = this._stepArrays(g.trace, src.x, src.y, src.n); +gl.bindBuffer(gl.ARRAY_BUFFER, g.xBuf); +gl.bufferData(gl.ARRAY_BUFFER, st ? st.x : src.x, gl.STATIC_DRAW); +gl.bindBuffer(gl.ARRAY_BUFFER, g.yBuf); +gl.bufferData(gl.ARRAY_BUFFER, st ? st.y : src.y, gl.STATIC_DRAW); +g.xMeta = { ...g.xMeta, offset: upd.x.offset, scale: upd.x.scale }; +g.yMeta = { ...g.yMeta, offset: upd.y.offset, scale: upd.y.scale }; +g._dashX = st ? st.x : src.x; +g._dashY = st ? st.y : src.y; +if (bArr) { +gl.bindBuffer(gl.ARRAY_BUFFER, g.baseBuf); +gl.bufferData(gl.ARRAY_BUFFER, sm ? sm.extra : bArr, gl.STATIC_DRAW); +g.baseMeta = { ...g.baseMeta, offset: upd.base.offset, scale: upd.base.scale }; +} +g.n = st ? st.n : src.n; +} +this.draw(); +} else if (msg.type === "density_update") { +if (msg.seq !== undefined && msg.seq !== this.seq) return; +const densityTraces = msg.traces || []; +const pendingTraceIds = new Set(densityTraces.map((upd) => Number(upd.id))); +if (pendingTraceIds.size === 0 && msg.trace !== undefined) { +pendingTraceIds.add(Number(msg.trace)); +} +const clearAllPending = pendingTraceIds.size === 0 && msg.stale; +const clearPending = (g) => { +if (msg.seq !== undefined && g._lodPendingSeq !== msg.seq) return; +g._lodPendingView = null; +g._lodPendingSeq = null; +g._lodPendingAt = null; +}; +if (pendingTraceIds.size || clearAllPending) { +for (const g of this.gpuTraces) { +if (g.tier !== "density") continue; +if (!clearAllPending && !pendingTraceIds.has(g.trace.id)) continue; +clearPending(g); +} +} +for (const upd of densityTraces) { +const g = this.gpuTraces.find((t) => t.trace.id === upd.id && t.tier === "density"); +if (!g) continue; +clearPending(g); +if (upd.mode === "points") { this._applyDrill(g, upd, buffers); continue; } +lodApplyDensityUpdate(this, g, upd, buffers); +} +this._pickable = this.gpuTraces.some( +(t) => markOf(t.trace.kind).pointPick && (t.tier !== "density" || t.drill)); +if (this._pickable && !this.pickFbo) this._initPickTarget(); +this.draw(); +} else if (msg.type === "append") { +this._applyAppend(msg, buffers); +} else if (msg.type === "pick_result") { +if (!msg.row) { this.tooltip.style.display = "none"; return; } +this._lastRow = msg.row; +const xy = this._lastHoverXY; +if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY); +if (this._interactionFlag("hover")) { +this._dispatchChartEvent("hover", { +row: msg.row, +trace: msg.row.trace, +index: msg.row.index, +exact: true, +view: this._eventView("hover"), +}); +} +} else if (msg.type === "selection") { +if (!msg.traces || !msg.traces.length) { +for (const g of this.gpuTraces) { +g.selActive = false; +if (g.drill) g.drill.selActive = false; +} +} else { +for (const upd of msg.traces) { +const g = this.gpuTraces.find((t) => t.trace.id === upd.id); +if (!g) continue; +const pg = g.tier === "density" ? g.drill : g; +if (!pg || !pg.n) continue; +if ( +g.tier === "density" && upd.drill_seq !== undefined && +pg.seq !== undefined && upd.drill_seq !== pg.seq +) continue; +const idx = this._asU32(buffers[upd.buf]); +const mask = new Float32Array(pg.n); +for (let i = 0; i < idx.length; i++) if (idx[i] < pg.n) mask[idx[i]] = 1; +this._applySelMask(pg, mask); +} +} +this._selectionCount = msg.total || 0; +this.draw(); +if (this._interactionFlag("select", true)) { +this._dispatchChartEvent("select", { +total: this._selectionCount, +view: this._eventView("select"), +}); +} +} +}, +_applyDrill(g, upd, buffers) { +lodApplyDrill(this, g, upd, buffers); +}, +_dropDrill(g) { +lodDropDrill(this, g); +}, +_viewInside(win) { +if (!win) return false; +const { x0, x1, y0, y1 } = this.view; +const ex = Math.abs(x1 - x0) * 1e-4, ey = Math.abs(y1 - y0) * 1e-4; +const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); +const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); +const wx0 = Math.min(win.x0, win.x1), wx1 = Math.max(win.x0, win.x1); +const wy0 = Math.min(win.y0, win.y1), wy1 = Math.max(win.y0, win.y1); +return vx0 >= wx0 - ex && vx1 <= wx1 + ex && vy0 >= wy0 - ey && vy1 <= wy1 + ey; +}, +_viewInsideRange(xRange, yRange) { +if (!xRange || !yRange) return false; +return this._viewInside({ x0: xRange[0], x1: xRange[1], y0: yRange[0], y1: yRange[1] }); +}, +}); +const RECT_MARK = { +build: (view, g, t, buffer) => view._buildRectMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +const edgePad = g.trace.kind === "histogram" +? [0, 0, view._edgePadForValue(0, y0, y1, view.canvas.height), 0] +: [0, 0, 0, 0]; +view._drawRects( +g, +view._map(g.x0Meta, x0, x1, g.xAxis), +view._map(g.x1Meta, x0, x1, g.xAxis), +view._map(g.y0Meta, y0, y1, g.yAxis), +view._map(g.y1Meta, y0, y1, g.yAxis), +edgePad +); +}, +refreshColor: (view, g) => { +if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); +view._rectMarkStyleGpu(g, g.trace); +}, +}; +const BAR_MARK = { +build: (view, g, t, buffer) => view._buildBarMark(g, t, buffer), +draw: (view, g) => { +if (!g.trace.bar) { +RECT_MARK.draw(view, g); +return; +} +const horizontal = g.orientation === 1; +const pAxis = horizontal ? g.yAxis : g.xAxis; +const vAxis = horizontal ? g.xAxis : g.yAxis; +const [p0, p1] = view._axisRange(pAxis); +const [v0, v1] = view._axisRange(vAxis); +const pmap = view._map(g.posMeta, p0, p1, pAxis); +const v1map = view._map(g.value1Meta, v0, v1, vAxis); +const v0map = g.value0Mode === 1 +? view._map(g.value0Meta, v0, v1, vAxis) +: null; +const v0Const = g.value0Mode === 0 +? view._mapConst(g.value0Const, v0, v1, vAxis) +: null; +const v0EdgePad = g.value0Mode === 0 +? view._edgePadForValue( +g.value0Const, +v0, +v1, +horizontal ? view.canvas.width : view.canvas.height +) +: 0; +view._drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad); +}, +refreshColor: (view, g) => { +if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); +view._rectMarkStyleGpu(g, g.trace); +}, +}; +const SEGMENT_MARK = { +build: (view, g, t, buffer) => view._buildSegmentMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +view._drawSegments( +g, +view._map(g.x0Meta, x0, x1, g.xAxis), +view._map(g.y0Meta, y0, y1, g.yAxis), +); +}, +refreshColor: (view, g) => { +if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); +}, +}; +const AREA_MARK = { +build: (view, g, t, buffer) => view._buildAreaMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +const xm = view._map(g.xMeta, x0, x1, g.xAxis); +const ym = view._map(g.yMeta, y0, y1, g.yAxis); +view._drawArea(g, xm, ym, view._map(g.baseMeta, y0, y1, g.yAxis)); +if ((g.trace.style.line_width ?? 0) > 0) { +view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); +if (g.trace.style.stroke_perimeter) { +const yBuf = g.yBuf, yMeta = g.yMeta, dashY = g._dashY; +g.yBuf = g.baseBuf; +g.yMeta = g.baseMeta; +g._dashY = g._cpu.base; +view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); +g.yBuf = yBuf; +g.yMeta = yMeta; +g._dashY = dashY; +} +} +}, +refreshColor: (view, g) => { +g.color = parseColor(view.root, g.trace.style.color, g.color); +g.lineColor = parseColor(view.root, g.trace.style.line_color || g.trace.style.color, g.lineColor || g.color); +g.grad = view._resolveMarkFill(g.trace.style, g.color); +}, +}; +const MESH_MARK = { +build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); +}, +refreshColor: (view, g) => { +if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); +const style = g.trace.style || {}; +g.meshStroke = parseColor(view.root, style.stroke || "transparent", [0, 0, 0, 0]); +}, +}; +const MARK_KINDS = { +histogram: RECT_MARK, +box: RECT_MARK, +violin: RECT_MARK, +errorbar: SEGMENT_MARK, +stem: SEGMENT_MARK, +box_whisker: SEGMENT_MARK, +box_median: SEGMENT_MARK, +contour: SEGMENT_MARK, +segments: SEGMENT_MARK, +triangle_mesh: MESH_MARK, +error_band: AREA_MARK, +hexbin: { +build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); +}, +refreshColor: (view, g) => { +if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); +const style = g.trace.style || {}; +g.meshStroke = parseColor(view.root, style.stroke || "transparent", [0, 0, 0, 0]); +}, +}, +bar: BAR_MARK, +column: BAR_MARK, +heatmap: { +build: (view, g, t, buffer) => view._buildHeatmapMark(g, t, buffer), +draw: (view, g) => view._drawHeatmap(g), +}, +scatter: { +build: (view, g, t, buffer) => view._buildScatterMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +view._drawPoints(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); +}, +pointPick: true, +retainCpu: true, +refreshColor: (view, g) => { +if (g.colorMode === 0 && g.trace.color) { +g.color = parseColor(view.root, g.trace.color.color, g.color); +} +view._pointMarkStyle(g, g.trace); +}, +}, +line: { +build: (view, g, t, buffer) => view._buildLineMark(g, t, buffer), +draw: (view, g) => { +const [x0, x1] = view._axisRange(g.xAxis); +const [y0, y1] = view._axisRange(g.yAxis); +view._drawLine(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); +}, +refreshColor: (view, g) => { +g.color = parseColor(view.root, g.trace.style.color, g.color); +}, +}, +area: AREA_MARK, +}; +function markOf(kind) { +return MARK_KINDS[kind] || MARK_KINDS.scatter; +} +function bytesToSpan(b) { +const span = fcByteSpan(b, "chart payload"); +return span.byteOffset % 4 === 0 ? span : new Uint8Array(span); +} + +function payloadBuffers(spec, raw) { +if (spec.buffer_layout === "split") { +if (!Array.isArray(raw)) { +throw new Error("xy: spec says buffer_layout=split but the transport delivered one buffer"); +} +return raw.map(bytesToSpan); +} +if (Array.isArray(raw)) { +throw new Error("xy: transport delivered a buffer list but the spec is not split-layout"); +} +return bytesToSpan(raw); +} +function render({ model, el }) { +const spec = model.get("spec"); +const buffer = payloadBuffers(spec, model.get("buffers")); +const comm = { +send: (msg) => model.send(msg), +onMessage: (cb) => { +const handler = (content, buffers) => cb(content, buffers); +model.on("msg:custom", handler); +return () => model.off?.("msg:custom", handler); +}, +}; +const view = new ChartView(el, spec, buffer, comm); +return () => view.destroy(); +} + +function renderStandalone(el, spec, arrayBuffer) { +const buffer = bytesToSpan(arrayBuffer); +const view = new ChartView(el, spec, buffer, null); +const column = (idx) => view._columnView(buffer, spec.columns[idx]); +for (const g of view.gpuTraces) { +if (markOf(g.trace.kind).retainCpu && g.tier !== "density") { +g._cpu = { +x: column(g.trace.x), +y: column(g.trace.y), +xMeta: g.xMeta, +yMeta: g.yMeta, +}; +if (g.trace.color && Number.isInteger(g.trace.color.buf)) { +g._cpu.color = column(g.trace.color.buf); +} +if (g.trace.size && Number.isInteger(g.trace.size.buf)) { +g._cpu.size = column(g.trace.size.buf); +} +} +} +return view; +} + +export { render, renderStandalone, decodeFrame, ChartView, MARK_KINDS, markOf }; +export default { render, decodeFrame }; diff --git a/python/reflex-xy/reflex_xy/component.py b/python/reflex-xy/reflex_xy/component.py new file mode 100644 index 00000000..4af916e8 --- /dev/null +++ b/python/reflex-xy/reflex_xy/component.py @@ -0,0 +1,78 @@ +"""The Reflex component: `reflex_xy.chart(State.figure_var, ...)`. + +The wrapper React component lives in `assets/XYChart.jsx` and is shipped as +a shared asset (the same mechanism reflex's own radix color-mode provider +uses for local JS). It is deliberately lazy: `rx.asset` symlinks into the +compiling app's `assets/` directory, so the component class is only built +the first time a chart is actually placed in a page tree. + +Semantic events cross the normal Reflex event system as small JSON — +row dicts and selection summaries, never data buffers (§2 of the design): + + reflex_xy.chart( + Dash.chart, + on_point_hover=Dash.hovered, # def hovered(self, row: dict) + on_point_click=Dash.clicked, # def clicked(self, row: dict) + on_select_end=Dash.selected, # def selected(self, sel: dict) + on_view_change=Dash.viewed, # def viewed(self, view: dict) + height="480px", + ) +""" + +from __future__ import annotations + +from typing import Any, Optional + +import reflex as rx + +from .assets import WRAPPER_TAG, register + +__all__ = ["chart"] + +# Lazily-built component class (see module doc); Any because reflex Component +# metaclasses defeat static typing of the create() classmethod. +_component_cls: Optional[Any] = None + + +def _build_component_cls() -> Any: + wrapper_library = register() + + class XYChart(rx.Component): + """A xy figure bound to a registry token.""" + + # The shared-asset module path ($/public/external/reflex_xy/assets/…): + # a local-JS library, never sent to the package manager. + library = wrapper_library + tag = WRAPPER_TAG + + # The figure token minted by @reflex_xy.figure (or register()). + token: rx.Var[str] + + # Semantic events out (small JSON by construction — §2). + on_point_hover: rx.EventHandler[lambda row: [row]] + on_point_click: rx.EventHandler[lambda row: [row]] + on_select_end: rx.EventHandler[lambda selection: [selection]] + on_view_change: rx.EventHandler[lambda view: [view]] + + # The class is created lazily inside this function; reflex derives JS + # identifiers from __qualname__, and "" would leak an illegal + # "<" into compiled import names. Present it as a module-level class. + XYChart.__qualname__ = "XYChart" + XYChart.__module__ = __name__ + return XYChart + + +def chart(token: Any, **props: Any) -> Any: + """Place a xy chart bound to `token` (a `@reflex_xy.figure` var + or a `reflex_xy.register()` token string). + + Sizing: the outer element defaults to `width: 100%` and a 420px height; + pass `width=`/`height=` (or any style prop) to override. Charts built + with `width="100%"` track the element responsively. + """ + global _component_cls + if _component_cls is None: + _component_cls = _build_component_cls() + props.setdefault("width", "100%") + props.setdefault("height", "420px") + return _component_cls.create(token=token, **props) diff --git a/python/reflex-xy/reflex_xy/namespace.py b/python/reflex-xy/reflex_xy/namespace.py new file mode 100644 index 00000000..7575e84a --- /dev/null +++ b/python/reflex-xy/reflex_xy/namespace.py @@ -0,0 +1,270 @@ +"""The xy data plane as a second socket.io namespace on Reflex's server. + +Transport decision (docs/design/reflex-integration.md): instead of new HTTP +endpoints, the data plane multiplexes onto the app's existing engine.io +websocket as its own namespace (`/_xy`). socket.io multiplexing means the +browser keeps ONE physical connection for app state and chart data; this +namespace inherits the connection's lifecycle, origin checks, and query +token — anything the app plane gains (auth on connect, proxy config, TLS) +the data plane gets for free, because it *is* the same connection. + +Wire shape: metadata is one small JSON object per event; every data column +rides as a native socket.io binary attachment (`bytes` values below), which +the browser receives as `ArrayBuffer`s in-place. No JSON numbers for data, +no base64 (§29) — and no custom length-prefix framing needed, because the +socket.io protocol already delimits attachments. + +Events, client -> server: + sub {fig, px?} subscribe; joins the figure room, replies `payload` + unsub {fig} leave the figure room + msg {fig, m} one channel.handle_message dispatch, reply `msg` + +Events, server -> client: + payload {fig, version, spec, buffers} first paint / full refresh + msg {fig, message, buffers} handle_message reply or push + err {fig, error} token unknown/foreign, rebuild failed + +Every inbound handler is total: malformed input drops or answers `err`, +never raises (a hostile client must not be able to crash the worker — +channel.py's contract, extended to the transport). +""" + +from __future__ import annotations + +import asyncio +import urllib.parse +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Optional + +from socketio import AsyncNamespace + +from xy.channel import handle_message + +from .registry import FigureEntry, FigureRegistry +from .tokens import parse_token + +if TYPE_CHECKING: + from xy._figure import Figure + +__all__ = ["XY_NAMESPACE", "XYNamespace"] + +#: socket.io namespace for the chart data plane. A namespace name is part of +#: the socket.io protocol, not a URL: it needs no route, no mount, and no +#: reverse-proxy entry beyond what the app's websocket already has. +XY_NAMESPACE = "/_xy" + +# One payload/message is screen-bounded by construction (§29); these caps are +# the transport's fail-closed backstop, not a tuning knob. +_MAX_PX_HINT = 8192 +_MIN_PX_HINT = 16 + +# An async callable(token) -> Figure | None: given a parseable figure token, +# rebuild the figure from Reflex state (wired by app.setup; see state_bridge). +RebuildHook = Callable[[str], Awaitable[Optional["Figure"]]] + + +def _plain(value: Any) -> Any: + """Best-effort JSON-safe copy for small reply metadata. + + Kernel replies may carry numpy scalars (pick rows). Data buffers never + pass through here — they ship as binary attachments. + """ + item = getattr(value, "item", None) + if callable(item) and getattr(value, "shape", None) == (): + return item() + if isinstance(value, dict): + return {k: _plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_plain(v) for v in value] + return value + + +def _buffer_bytes(buffers: Any) -> list[bytes]: + """socket.io attaches `bytes`/`bytearray` only; memoryviews must convert. + + This is the single wire copy of each column (the join copy the split + payload layout avoids does not come back — each column converts alone). + """ + return [b if isinstance(b, (bytes, bytearray)) else bytes(b) for b in (buffers or [])] + + +class XYNamespace(AsyncNamespace): + """Serve registry figures to browser clients over the shared socket.""" + + def __init__( + self, + registry: FigureRegistry, + *, + namespace: str = XY_NAMESPACE, + rebuild: Optional[RebuildHook] = None, + ) -> None: + super().__init__(namespace) + self.registry = registry + self._rebuild = rebuild + + # -- connection lifecycle ------------------------------------------------ + + async def on_connect(self, sid: str, environ: dict) -> None: + """Record the Reflex client token this connection authenticated with. + + The engine.io connection is shared with Reflex's `/_event` namespace, + so the same `?token=` query string reaches us — session affinity and + any future connection auth are inherited, not reimplemented. + """ + query = urllib.parse.parse_qs(environ.get("QUERY_STRING", "")) + token_list = query.get("token", []) + await self.save_session(sid, {"client_token": token_list[0] if token_list else None}) + + async def on_disconnect(self, sid: str) -> None: + """Rooms are cleaned up by socket.io; figures outlive the socket. + + Deliberate: a dropped connection (reload, laptop lid, transient + network) must not destroy server-side figures — the client + resubscribes with the same tokens on reconnect. + """ + + # -- subscription ---------------------------------------------------------- + + async def on_sub(self, sid: str, data: Any) -> None: + token, entry = await self._entry_for(sid, data, allow_rebuild=True) + if token is None or entry is None: + return + px = self._px_hint(data) + await self.enter_room(sid, self._room(token)) + async with entry.lock: + spec, raw = await asyncio.to_thread(entry.figure.build_payload_split, px) + await self.emit( + "payload", + { + "fig": token, + "version": entry.version, + "spec": spec, + "buffers": _buffer_bytes(raw), + }, + to=sid, + ) + + async def on_unsub(self, sid: str, data: Any) -> None: + token = self._token_of(data) + if token is not None: + await self.leave_room(sid, self._room(token)) + + # -- interaction round-trips ---------------------------------------------- + + async def on_msg(self, sid: str, data: Any) -> None: + token, entry = await self._entry_for(sid, data, allow_rebuild=True) + if token is None or entry is None: + return + content = data.get("m") if isinstance(data, dict) else None + async with entry.lock: + # Kernel work off the event loop: the Rust kernels release the + # GIL, so a slow view recompute never stalls app-plane traffic. + reply = await asyncio.to_thread(handle_message, entry.figure, content, None) + if reply is None: + return + message, buffers = reply + envelope: dict[str, Any] = { + "fig": token, + "message": _plain(message), + "buffers": _buffer_bytes(buffers), + } + # Replies are mount-addressed: several charts on one page share one + # socket, so the client tags requests with a mount id and we echo it. + mid = data.get("mid") if isinstance(data, dict) else None + if isinstance(mid, str) and len(mid) <= 64: + envelope["mid"] = mid + await self.emit("msg", envelope, to=sid) + + # -- server-side pushes (append/refresh fan-out) --------------------------- + + async def broadcast_message( + self, token: str, message: dict[str, Any], buffers: Optional[list[bytes]] = None + ) -> None: + """Push one channel message to every subscriber of a figure.""" + await self.emit( + "msg", + { + "fig": token, + "message": _plain(message), + "buffers": _buffer_bytes(buffers), + }, + room=self._room(token), + ) + + async def broadcast_payload(self, token: str, entry: FigureEntry) -> None: + """Push a full refreshed payload (figure rebuilt) to subscribers.""" + async with entry.lock: + spec, raw = await asyncio.to_thread(entry.figure.build_payload_split) + await self.emit( + "payload", + { + "fig": token, + "version": entry.version, + "spec": spec, + "buffers": _buffer_bytes(raw), + }, + room=self._room(token), + ) + + # -- internals --------------------------------------------------------------- + + @staticmethod + def _room(token: str) -> str: + return f"fig:{token}" + + @staticmethod + def _token_of(data: Any) -> Optional[str]: + if not isinstance(data, dict): + return None + token = data.get("fig") + if not isinstance(token, str) or not token or len(token) > 512: + return None + return token + + @staticmethod + def _px_hint(data: Any) -> Optional[int]: + try: + px = int(data.get("px")) + except (AttributeError, TypeError, ValueError): + return None + return max(_MIN_PX_HINT, min(_MAX_PX_HINT, px)) + + async def _entry_for( + self, sid: str, data: Any, *, allow_rebuild: bool + ) -> tuple[Optional[str], Optional[FigureEntry]]: + """Resolve a message's figure token to a registry entry. + + Enforces token affinity: a state-derived figure token embeds the + client token it was minted for, and only the connection carrying that + same client token may touch it. Imperative (opaque) tokens have no + embedded identity — they rely on unguessability, like the client + token itself. + """ + token = self._token_of(data) + if token is None: + return None, None + parsed = parse_token(token) + if parsed is not None: + session = await self.get_session(sid) + if session.get("client_token") != parsed.client_token: + await self._err(sid, token, "figure belongs to another session") + return token, None + entry = self.registry.get(token) + if entry is None and parsed is not None and allow_rebuild and self._rebuild is not None: + # Registry miss: worker restarted, or the reconnect landed on a + # node that never built this figure. Reflex state is the durable + # record — rebuild the figure from it and carry on (§27 applied + # to processes: every registered figure is a rebuildable cache). + try: + figure = await self._rebuild(token) + except Exception: # noqa: BLE001 - rebuild runs user builder code + figure = None + if figure is not None: + entry = self.registry.publish(token, figure, broadcast=False) + if entry is None: + await self._err(sid, token, "unknown figure token") + return token, None + return token, entry + + async def _err(self, sid: str, token: Optional[str], error: str) -> None: + await self.emit("err", {"fig": token, "error": error}, to=sid) diff --git a/python/reflex-xy/reflex_xy/registry.py b/python/reflex-xy/reflex_xy/registry.py new file mode 100644 index 00000000..2613f200 --- /dev/null +++ b/python/reflex-xy/reflex_xy/registry.py @@ -0,0 +1,280 @@ +"""Per-process figure registry: tokens in Reflex state, figures in here. + +The registry is deliberately NOT a distributed store (see +docs/design/reflex-integration.md §4): Reflex state is the durable, +already-distributed source of truth, and every registered figure is a +rebuildable cache of it — the same rule the dossier applies to GPU buffers +(§27). A registry miss (worker restart, reconnect landing on another node) +is recovered by re-running the figure's builder against state, not by +shipping canonical columns through Redis. + +Thread model: Reflex runs async handlers on the event loop and sync handlers +in a thread pool, so registry mutation is guarded by a plain threading lock +and never awaits. Broadcast fan-out (the async part) is scheduled onto the +loop captured at setup time; see `schedule_broadcast`. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from xy._figure import Figure + +__all__ = ["FigureEntry", "FigureRegistry", "registry"] + +# Idle figures are swept after this long without a subscribe/message/publish. +# Deterministic (state-backed) figures rebuild transparently on the next +# subscribe, so the TTL only bounds memory, not correctness. Imperative +# `register()` figures do not come back — the sweep is their documented limit. +DEFAULT_TTL_SECONDS = 30 * 60.0 +_SWEEP_INTERVAL_SECONDS = 60.0 + + +@dataclass +class FigureEntry: + """One live figure and its wire bookkeeping.""" + + figure: "Figure" + token: str + version: int = 1 + last_access: float = field(default_factory=time.monotonic) + # Serializes kernel calls per figure; concurrent figures still + # parallelize (the kernels release the GIL on the Rust side). + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + def touch(self) -> None: + self.last_access = time.monotonic() + + +class FigureRegistry: + """token -> FigureEntry map with versioning and TTL sweep.""" + + def __init__(self, ttl_seconds: float = DEFAULT_TTL_SECONDS) -> None: + self._entries: dict[str, FigureEntry] = {} + self._mutex = threading.RLock() + self._ttl = float(ttl_seconds) + # Tokens with a broadcast scheduled but not yet started. Publishes + # racing an un-started broadcast coalesce into it: the callback reads + # the entry live, so subscribers always get the newest payload and + # never a stale intermediate one. + self._pending_broadcasts: set[str] = set() + # Captured by setup(); lets sync-handler threads schedule async + # broadcasts safely. None until the data plane is attached. + self._loop: Optional[asyncio.AbstractEventLoop] = None + # async callback(token, entry) -> None wired by the namespace so + # publishes reach subscribed clients without a module cycle. + self._on_publish: Optional[Callable[[str, FigureEntry], Awaitable[None]]] = None + # async callback(token, message, buffers) -> None for incremental + # pushes (append) — same seam, message-shaped instead of payload-shaped. + self._on_push: Optional[Callable[[str, dict, list[bytes]], Awaitable[None]]] = None + + # -- wiring ------------------------------------------------------------ + + def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + def on_publish(self, callback: Callable[[str, FigureEntry], Awaitable[None]]) -> None: + self._on_publish = callback + + def on_push(self, callback: Callable[[str, dict, list[bytes]], Awaitable[None]]) -> None: + self._on_push = callback + + # -- core map ---------------------------------------------------------- + + def get(self, token: str) -> Optional[FigureEntry]: + with self._mutex: + entry = self._entries.get(token) + if entry is not None: + entry.touch() + return entry + + def publish(self, token: str, figure: "Figure", *, broadcast: bool = True) -> FigureEntry: + """Insert or replace a figure under `token` and bump its version. + + Re-publishing the same Figure object is a no-op version-wise unless + `figure` changed identity: state-var recomputes build a fresh figure, + which is the signal that subscribers need a new payload. + """ + with self._mutex: + entry = self._entries.get(token) + if entry is None: + entry = FigureEntry(figure=figure, token=token) + self._entries[token] = entry + changed = True + else: + changed = entry.figure is not figure + if changed: + entry.figure = figure + entry.version += 1 + entry.touch() + if broadcast and changed: + # Re-publishing the identical object means nothing moved; a new + # figure object is the signal subscribers need a fresh payload. + self.schedule_broadcast(token) + return entry + + def register(self, figure: "Figure") -> str: + """Imperative registration: mint an opaque token for a figure. + + The caller owns the lifecycle (`release`) and the figure cannot be + rebuilt on another node — this is the dev-tier API; the durable path + is the `@reflex_xy.figure` state var (see tokens.py). + """ + token = f"xyfig-{uuid.uuid4().hex}" + self.publish(token, figure, broadcast=False) + return token + + def release(self, token: str) -> None: + with self._mutex: + self._entries.pop(token, None) + + def tokens(self) -> list[str]: + with self._mutex: + return list(self._entries) + + def __len__(self) -> int: + with self._mutex: + return len(self._entries) + + # -- version bump + fan-out --------------------------------------------- + + def bump(self, token: str) -> Optional[FigureEntry]: + """Record an in-place mutation (e.g. append) without broadcast.""" + with self._mutex: + entry = self._entries.get(token) + if entry is None: + return None + entry.version += 1 + entry.touch() + return entry + + def schedule_broadcast(self, token: str) -> None: + """Fan a publish out to subscribers from any thread. + + Safe no-op before setup() (no loop yet: nobody can be subscribed + either, because the namespace is what wires the loop). + """ + callback = self._on_publish + loop = self._loop + if callback is None or loop is None: + return + with self._mutex: + if token in self._pending_broadcasts: + return # an un-started broadcast will already ship this state + self._pending_broadcasts.add(token) + + async def _run() -> None: + with self._mutex: + self._pending_broadcasts.discard(token) + entry = self._entries.get(token) + if entry is not None: + await callback(token, entry) + + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is loop: + loop.create_task(_run()) + else: + asyncio.run_coroutine_threadsafe(_run(), loop) + + def append( + self, + token: str, + x: Any, + y: Any, + *, + color: Any = None, + size: Any = None, + trace: int = 0, + ) -> None: + """Stream-append points to a figure and push the delta to subscribers. + + Callable from anywhere — background tasks, sync handlers (thread + pool), or plain scripts. On a wired app the mutation and fan-out run + on the serving loop under the figure's lock; unwired (tests, + headless) the append applies synchronously and there is nobody to + push to. + """ + loop = self._loop + if loop is None: + entry = self.get(token) + if entry is None: + msg = f"unknown figure token: {token!r}" + raise KeyError(msg) + entry.figure.append(trace, x, y, color=color, size=size) + self.bump(token) + return + + async def _do() -> None: + entry = self.get(token) + if entry is None: + return + async with entry.lock: + message, buffers = await asyncio.to_thread( + entry.figure.append, trace, x, y, color=color, size=size + ) + self.bump(token) + push = self._on_push + if push is not None: + await push(token, message, list(buffers)) + + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is loop: + loop.create_task(_do()) + else: + asyncio.run_coroutine_threadsafe(_do(), loop) + + # -- TTL sweep ----------------------------------------------------------- + + def sweep(self, *, now: Optional[float] = None) -> list[str]: + """Drop entries idle past the TTL; returns the dropped tokens.""" + now = time.monotonic() if now is None else now + dropped: list[str] = [] + with self._mutex: + for token, entry in list(self._entries.items()): + if now - entry.last_access > self._ttl: + del self._entries[token] + dropped.append(token) + return dropped + + async def sweep_forever(self) -> None: + """Lifespan task: periodic TTL sweep (backstop for leaked tabs).""" + while True: + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + self.sweep() + + +#: Process-wide registry. One per backend worker by design — see module doc. +#: Everything references this exact object; tests reset it in place. +registry: FigureRegistry = FigureRegistry() + + +def reset_registry_for_tests() -> FigureRegistry: + """Reset the process registry in place (test isolation only).""" + registry._entries.clear() + registry._pending_broadcasts.clear() + registry._loop = None + registry._on_publish = None + registry._on_push = None + registry._ttl = DEFAULT_TTL_SECONDS + return registry + + +def _figure_of(chart: Any) -> "Figure": + """Accept either a public `xy.Chart` or an internal Figure.""" + figure = getattr(chart, "figure", None) + if callable(figure): + return figure() + return chart diff --git a/python/reflex-xy/reflex_xy/state_bridge.py b/python/reflex-xy/reflex_xy/state_bridge.py new file mode 100644 index 00000000..a1f1a733 --- /dev/null +++ b/python/reflex-xy/reflex_xy/state_bridge.py @@ -0,0 +1,71 @@ +"""Rebuild figures from Reflex state: the distributed-deployment answer. + +The figure registry is process-local. What makes that safe in a +multi-worker / reconnecting world is this module: given a state token +(`xyv1|client|state|var`) and the app's state manager, we can always +recover the figure by re-running the builder against the session's state — +which Reflex already stores durably (memory/disk/redis) and already knows +how to hand to any worker. No figure server, no data in Redis beyond the +state that was there anyway (§27 applied to processes: the figure is a +rebuildable cache, Reflex state is canonical). + +Read-only by design: rebuilds use `state_manager.get_state` (no state lock, +no delta emission). Builders must therefore be pure functions of state — +the same contract cached computed vars already impose. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from .registry import _figure_of +from .tokens import ParsedToken, builder_of, parse_token + +if TYPE_CHECKING: + from xy._figure import Figure + +__all__ = ["make_rebuild_hook", "rebuild_figure"] + + +def _resolve_state_cls(state_full_name: str) -> Any: + """State full name (as stored in the token) -> state class. + + Mirrors reflex's own legacy-token resolution + (`BaseStateToken.from_legacy_token`): the full name is split on dots and + resolved from the root state class. + """ + import reflex as rx + + return rx.State.get_class_substate(tuple(state_full_name.split("."))) + + +async def rebuild_figure(app: Any, parsed: ParsedToken) -> Optional["Figure"]: + """Re-run a figure var's builder against the session's stored state.""" + import reflex as rx + + try: + state_cls = _resolve_state_cls(parsed.state_full_name) + except (KeyError, ValueError): + return None + builder = builder_of(state_cls, parsed.var_name) + if builder is None: + return None + token = rx.BaseStateToken(ident=parsed.client_token, cls=rx.State) + root = await app.state_manager.get_state(token) + substate = await root.get_state(state_cls) + chart = builder(substate) + if chart is None: + return None + return _figure_of(chart) + + +def make_rebuild_hook(app: Any) -> Any: + """The namespace's RebuildHook, bound to one app instance.""" + + async def _rebuild(token_str: str) -> Optional["Figure"]: + parsed = parse_token(token_str) + if parsed is None: + return None + return await rebuild_figure(app, parsed) + + return _rebuild diff --git a/python/reflex-xy/reflex_xy/tokens.py b/python/reflex-xy/reflex_xy/tokens.py new file mode 100644 index 00000000..78959ea2 --- /dev/null +++ b/python/reflex-xy/reflex_xy/tokens.py @@ -0,0 +1,88 @@ +"""Figure tokens: the only chart-related value that lives in Reflex state. + +Two token families, one namespace: + +- **State tokens** (`xyv1|||`) are + minted by the `@reflex_xy.figure` computed var. They are *deterministic*: + any backend worker holding the same Reflex state can re-derive the figure + from the token alone, which is what makes reconnects and multi-worker + deployments work without a central figure store (the token IS the recipe; + Reflex state is the pantry). +- **Opaque tokens** (`xyfig-`) come from imperative + `reflex_xy.register(...)`. They cannot be rebuilt elsewhere — dev-tier by + design, documented in docs/design/reflex-integration.md. + +Tokens are visible to their own client (they ride through state deltas), so +they must not carry anything the client doesn't already know: the client +token is the browser tab's own session id, and state/var names already +appear in every state delta. Cross-client use is refused by the namespace's +affinity check, not by token secrecy. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional + +__all__ = [ + "ParsedToken", + "build_state_token", + "builder_of", + "parse_token", +] + +_PREFIX = "xyv1" +_SEP = "|" +# Client tokens are UUID-ish; state full names and var names are dotted +# Python identifiers. Nothing here may contain the separator. +_TOKEN_RE = re.compile( + r"^xyv1\|(?P[A-Za-z0-9_-]{8,64})" + r"\|(?P[A-Za-z0-9_.]{1,512})" + r"\|(?P[A-Za-z_][A-Za-z0-9_]{0,255})$" +) + +#: Attribute stashed on a figure var's fget carrying the user's builder. +#: It lives on the *function* (not the ComputedVar) so it survives reflex's +#: `_replace` copies, which re-instantiate the var but thread fget through. +BUILDER_ATTR = "__xy_builder__" + + +@dataclass(frozen=True) +class ParsedToken: + client_token: str + state_full_name: str + var_name: str + + +def build_state_token(client_token: str, state_full_name: str, var_name: str) -> str: + token = _SEP.join((_PREFIX, client_token, state_full_name, var_name)) + if parse_token(token) is None: + # Defensive: a state or client token that defeats the grammar would + # otherwise mint a token the namespace can never resolve. + msg = f"cannot build a valid figure token from {client_token!r}/{state_full_name!r}/{var_name!r}" + raise ValueError(msg) + return token + + +def parse_token(token: str) -> Optional[ParsedToken]: + """Parse a state token; None for opaque/foreign strings (fail closed).""" + if not isinstance(token, str): + return None + match = _TOKEN_RE.match(token) + if match is None: + return None + return ParsedToken( + client_token=match["client"], + state_full_name=match["state"], + var_name=match["var"], + ) + + +def builder_of(state_cls: Any, var_name: str) -> Optional[Callable[[Any], Any]]: + """Find the figure builder a `@reflex_xy.figure` var attached to a state class.""" + computed = getattr(state_cls, "computed_vars", None) + var = computed.get(var_name) if isinstance(computed, dict) else None + fget = getattr(var, "_fget", None) + return getattr(fget, BUILDER_ATTR, None) diff --git a/python/reflex-xy/reflex_xy/vars.py b/python/reflex-xy/reflex_xy/vars.py new file mode 100644 index 00000000..8f08c180 --- /dev/null +++ b/python/reflex-xy/reflex_xy/vars.py @@ -0,0 +1,131 @@ +"""`@reflex_xy.figure`: a computed var that *is* the chart registration. + +The pattern (docs/design/reflex-integration.md): the state method builds the +chart from state, the computed var's value is only the figure *token*, and +evaluating the var is what (re)registers the figure in the per-process +registry. Reflex's own dependency tracking decides when that happens: + +- first render: var evaluates -> figure built -> token into state. +- a dependency changes: reflex marks the var dirty, the next delta + evaluation rebuilds the figure and re-publishes it; subscribers get the + fresh payload pushed over the data plane. The token itself is stable, so + the *frontend* sees no prop change at all — data moves, DOM doesn't. +- reconnect on another worker: the cached token comes back with the state, + the component resubscribes, the registry misses, and the namespace + rebuilds from state via the builder this module attached to the var. + +The builder must be a pure function of its state instance (same discipline +as any cached computed var) — that purity is exactly what makes the figure +a rebuildable cache instead of precious process state. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional, overload + +from reflex_base.vars.base import ComputedVar + +from .registry import _figure_of, registry +from .tokens import BUILDER_ATTR, build_state_token + +__all__ = ["FigureVar", "figure"] + + +class FigureVar(ComputedVar): + """ComputedVar whose value is a figure token and whose dependencies are + the *builder's* — reflex tracks what the chart reads, not what the + token-minting wrapper reads.""" + + def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: + if obj is None: + builder = getattr(self._fget, BUILDER_ATTR, None) + if builder is not None: + obj = builder + return super()._deps(objclass, obj=obj) + + +def _make_fget(builder: Callable[[Any], Any]) -> Callable[[Any], str]: + builder_name = _fn_name(builder) + + def fget(self: Any) -> str: + client_token = self.router.session.client_token + if not client_token: + # Pre-hydration evaluation (e.g. initial state snapshot at + # compile time): no session yet, so no figure to serve. The + # component treats "" as "not ready" and waits for the + # hydrated value. + return "" + token = build_state_token(client_token, type(self).get_full_name(), builder_name) + chart = builder(self) + if chart is None: + registry.release(token) + return "" + registry.publish(token, _figure_of(chart)) + return token + + fget.__name__ = builder_name + fget.__qualname__ = getattr(builder, "__qualname__", builder_name) + fget.__module__ = getattr(builder, "__module__", fget.__module__) + fget.__doc__ = builder.__doc__ + setattr(fget, BUILDER_ATTR, builder) + return fget + + +def _fn_name(fn: Callable[..., Any]) -> str: + name = getattr(fn, "__name__", "") + if not name: + msg = f"@reflex_xy.figure builders must be named functions, got {fn!r}" + raise TypeError(msg) + return name + + +@overload +def figure(builder: Callable[[Any], Any]) -> FigureVar: ... + + +@overload +def figure( + builder: None = None, **var_kwargs: Any +) -> Callable[[Callable[[Any], Any]], FigureVar]: ... + + +def figure( + builder: Optional[Callable[[Any], Any]] = None, **var_kwargs: Any +) -> "FigureVar | Callable[[Callable[[Any], Any]], FigureVar]": + """Declare a chart on a Reflex state class. + + Usage:: + + class Dash(rx.State): + n: int = 100_000 + + @reflex_xy.figure + def chart(self) -> fc.Chart: + x, y = self._points(self.n) + return fc.scatter_chart(fc.scatter(x, y)) + + # in the page: reflex_xy.chart(Dash.chart, height="480px") + + The method must return a public ``xy`` chart (or an internal + Figure), or ``None`` for "no chart right now". Keyword arguments pass + through to reflex's ``ComputedVar`` (``deps=``, ``auto_deps=``, + ``interval=``, ...); dependencies are auto-tracked from the builder's + body by default, exactly like a normal ``@rx.var``. + """ + + def _decorate(fn: Callable[[Any], Any]) -> FigureVar: + if _fn_name(fn).startswith("_"): + # Backend (underscore) vars never reach the client, but the + # token must — refuse early with a clear message instead of + # compiling a chart nobody can subscribe to. + msg = ( + "@reflex_xy.figure vars must not start with '_' (the token must sync to the client)" + ) + raise ValueError(msg) + var_kwargs.setdefault("cache", True) + return FigureVar(fget=_make_fget(fn), return_type=str, **var_kwargs) + + if builder is None: + return _decorate + return _decorate(builder) diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py new file mode 100644 index 00000000..fce8a220 --- /dev/null +++ b/scripts/reflex_ws_smoke.py @@ -0,0 +1,320 @@ +"""End-to-end probe for the Reflex integration (reflex-integration.md §1/§2). + +Drives headless Chromium at a *running* reflex-xy demo app (see +python/reflex-xy/examples/demo_app: `reflex run`) and asserts the load-bearing +claims of the design: + +1. ONE physical websocket to the backend carries both the app plane and the + chart data plane (socket.io namespace multiplexing) — counted via CDP. +2. All three charts paint real pixels from binary socket payloads (screenshot + evidence; there are no HTTP data endpoints to fall back on). +3. Deep zoom drills the 1M-point density scatter to exact points + (density_view round-trips over the socket, §16), and hovering a drilled + 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. + +Usage: + python3 scripts/reflex_ws_smoke.py [--frontend http://localhost:3100] + +Stdlib only (repo CDP driver + a minimal PNG reader); needs the demo app +already serving and a Chromium binary. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import shutil +import struct +import sys +import time +import zlib +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python")) + +from xy._chromium import ChromiumSession # noqa: E402 + +CHROMIUM_CANDIDATES = [ + "/opt/pw-browsers/chromium", + "chromium", + "chromium-browser", + "google-chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", +] + + +def find_chromium() -> str: + for candidate in CHROMIUM_CANDIDATES: + path = Path(candidate) + if path.is_dir(): + for sub in ("chrome-linux/chrome", "chrome"): + if (path / sub).exists(): + return str(path / sub) + hits = sorted(path.glob("**/chrome")) + if hits: + return str(hits[0]) + resolved = shutil.which(candidate) if not path.is_absolute() else candidate + if resolved and Path(resolved).exists() and not Path(resolved).is_dir(): + return resolved + raise SystemExit("no chromium found; set --chromium") + + +def decode_png(data: bytes) -> tuple[int, int, int, bytearray]: + """Minimal PNG reader (8-bit RGB/RGBA, no interlace) -> (w, h, channels, pixels).""" + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + pos, width, height, channels, idat = 8, 0, 0, 0, b"" + while pos < len(data): + (length,) = struct.unpack(">I", data[pos : pos + 4]) + ctype = data[pos + 4 : pos + 8] + body = data[pos + 8 : pos + 8 + length] + pos += 12 + length + if ctype == b"IHDR": + width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body) + if depth != 8 or interlace: + raise ValueError("unsupported PNG variant") + channels = {2: 3, 6: 4}[color] + elif ctype == b"IDAT": + idat += body + elif ctype == b"IEND": + break + raw = zlib.decompress(idat) + stride = width * channels + out = bytearray(width * height * channels) + prev = bytearray(stride) + src = 0 + for y in range(height): + filt = raw[src] + src += 1 + line = bytearray(raw[src : src + stride]) + src += stride + if filt == 1: # Sub + for i in range(channels, stride): + line[i] = (line[i] + line[i - channels]) & 0xFF + elif filt == 2: # Up + for i in range(stride): + line[i] = (line[i] + prev[i]) & 0xFF + elif filt == 3: # Average + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF + elif filt == 4: # Paeth + for i in range(stride): + a = line[i - channels] if i >= channels else 0 + b = prev[i] + c = prev[i - channels] if i >= channels else 0 + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + out[y * stride : (y + 1) * stride] = line + prev = line + return width, height, channels, out + + +def ink_fraction(png: bytes, rect: dict, dpr: float) -> float: + """Fraction of pixels inside rect that differ from near-white.""" + width, height, channels, px = decode_png(png) + x0 = max(0, int(rect["x"] * dpr)) + y0 = max(0, int(rect["y"] * dpr)) + x1 = min(width, int((rect["x"] + rect["w"]) * dpr)) + y1 = min(height, int((rect["y"] + rect["h"]) * dpr)) + total = max(1, (x1 - x0) * (y1 - y0)) + ink = 0 + for y in range(y0, y1): + row = (y * width) * channels + for x in range(x0, x1): + o = row + x * channels + if px[o] < 245 or px[o + 1] < 245 or px[o + 2] < 245: + ink += 1 + return ink / total + + +class Probe: + """One attached tab with Runtime/Network/Input access.""" + + def __init__(self, session: ChromiumSession, url: str) -> None: + self.s = session + target = session._call("Target.createTarget", {"url": "about:blank"}) + attached = session._call( + "Target.attachToTarget", {"targetId": target["targetId"], "flatten": True} + ) + self.sid = attached["sessionId"] + self._call("Network.enable") + self._call("Page.enable") + self._call("Runtime.enable") + # NOTE: no Emulation.setDeviceMetricsOverride here — with emulation + # active, synthetic mouseMoved events stop producing the pointermove + # stream hover picking listens to (wheel/click still work). Headless + # runs at its default viewport instead; rect-relative math below + # keeps the assertions layout-independent. + self._call("Page.navigate", {"url": url}) + + def _call(self, method: str, params: dict | None = None, timeout_s: float = 60.0): + return self.s._call(method, params, session_id=self.sid, timeout_s=timeout_s) + + def eval(self, expression: str, timeout_s: float = 30.0): + reply = self._call( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True, "awaitPromise": True}, + timeout_s=timeout_s, + ) + if reply.get("exceptionDetails"): + raise RuntimeError(f"page exception: {json.dumps(reply['exceptionDetails'])[:400]}") + return reply.get("result", {}).get("value") + + def wait_for(self, expression: str, *, timeout_s: float = 60.0, label: str = "condition"): + deadline = time.monotonic() + timeout_s + last = None + while time.monotonic() < deadline: + last = self.eval(expression) + if last: + return last + time.sleep(0.25) + raise SystemExit(f"timeout waiting for {label}; last={last!r}") + + def backend_websockets(self) -> list[str]: + """Websockets to the app backend (excludes vite's dev-mode HMR socket).""" + self.eval("1") # pump queued CDP events + urls: list[str] = [] + for (sid, method), events in self.s._events.items(): + if sid == self.sid and method == "Network.webSocketCreated": + urls.extend(e.get("url", "") for e in events) + return [u for u in urls if "/_event" in u or "/_xy" in u] + + def screenshot(self) -> bytes: + shot = self._call( + "Page.captureScreenshot", + {"format": "png", "captureBeyondViewport": True}, + timeout_s=60.0, + ) + return base64.b64decode(shot["data"]) + + def rect(self, element_id: str, *, page_coords: bool = False) -> dict: + scroll = "window.scrollX, window.scrollY" if page_coords else "0, 0" + return self.eval( + f"(() => {{ const [sx, sy] = [{scroll}];" + f" const r = document.getElementById('{element_id}').getBoundingClientRect();" + " return {x: r.x + sx, y: r.y + sy, w: r.width, h: r.height}; })()" + ) + + def scroll_to(self, element_id: str) -> None: + self.eval( + f"document.getElementById('{element_id}')" + ".scrollIntoView({block: 'center', behavior: 'instant'})" + ) + time.sleep(0.3) + + def mouse(self, kind: str, x: float, y: float, **extra): + self._call( + "Input.dispatchMouseEvent", + {"type": kind, "x": x, "y": y, **extra}, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--frontend", default="http://localhost:3100") + parser.add_argument("--chromium", default=None) + parser.add_argument("--screenshot", default=None, help="save the final page PNG here") + args = parser.parse_args() + + chromium = args.chromium or find_chromium() + print(f"chromium: {chromium}") + failures: list[str] = [] + + with ChromiumSession(chromium, gl="software", sandbox=False) as session: + probe = Probe(session, args.frontend) + + # 1) all three charts mount live views fed by socket payloads + probe.wait_for( + "window.__xy_views && window.__xy_views.size >= 3", + timeout_s=120.0, + label="3 mounted chart views", + ) + print("mounted views:", probe.eval("Array.from(window.__xy_views.keys()).sort()")) + + # 2) exactly one physical websocket to the backend for both planes + time.sleep(1.5) + ws = probe.backend_websockets() + print(f"backend websockets: {len(ws)}") + if len(ws) != 1: + failures.append(f"expected exactly 1 backend websocket (shared transport), got {ws}") + + # 3) pixels: every chart paints ink inside its rect (full-page shot, + # rects in page coordinates so below-the-fold charts count too) + time.sleep(1.0) + png = probe.screenshot() + for chart_id, min_ink in (("cloud", 0.02), ("hist", 0.02), ("live", 0.005)): + frac = ink_fraction(png, probe.rect(chart_id, page_coords=True), 1.0) + print(f"{chart_id}: ink fraction {frac:.2%}") + if frac < min_ink: + failures.append(f"{chart_id} looks blank ({frac:.2%} < {min_ink:.0%})") + + # 4) deep zoom drills density -> exact points (§16 over the socket) … + rect = probe.rect("cloud") + cx, cy = rect["x"] + rect["w"] * 0.55, rect["y"] + rect["h"] * 0.5 + probe.mouse("mouseMoved", cx, cy) + for _ in range(16): + probe.mouse("mouseWheel", cx, cy, deltaX=0, deltaY=-240) + time.sleep(0.15) + probe.wait_for( + "(() => { const g = window.__xy_views.get('cloud').gpuTraces[0];" + " return !!(g && (g.drill || g.tier !== 'density')); })()", + timeout_s=60.0, + label="density drill to exact points", + ) + print("drill: density tier swapped to exact points") + + # … and hovering a drilled point closes the semantic event loop: + # GPU pick -> socket pick round-trip -> reflex event -> state delta. + for dx in range(-40, 200, 8): + probe.mouse("mouseMoved", cx + dx / 4, cy + dx / 16) + time.sleep(0.12) + try: + found_row = probe.wait_for( + "(document.body.innerText.match(/x=-?[0-9.]+/) || [null])[0]", + timeout_s=15.0, + label="hover readout", + ) + print(f"hover readout shows picked row: {found_row!r}") + except SystemExit: + failures.append("hover over drilled points never updated the reflex readout") + + # 5) streaming: click go-live, live trace vertex count must grow + n_before = probe.eval("(window.__xy_views.get('live').gpuTraces[0] || {n: 0}).n || 0") + probe.scroll_to("stream-btn") # the button sits below the fold + btn = probe.eval( + "(() => { const b = document.getElementById('stream-btn');" + " const r = b.getBoundingClientRect();" + " return {x: r.x + r.width / 2, y: r.y + r.height / 2}; })()" + ) + probe.mouse("mouseMoved", btn["x"], btn["y"]) + probe.mouse("mousePressed", btn["x"], btn["y"], button="left", buttons=1, clickCount=1) + probe.mouse("mouseReleased", btn["x"], btn["y"], button="left", buttons=0, clickCount=1) + probe.wait_for( + "(() => { const g = window.__xy_views.get('live').gpuTraces[0];" + f" return !!(g && g.n > {n_before} + 2); }})()", + timeout_s=30.0, + label="live trace growing via append pushes", + ) + n_after = probe.eval("window.__xy_views.get('live').gpuTraces[0].n") + print(f"live stream: {n_before} -> {n_after} vertices (append pushes)") + + if args.screenshot: + Path(args.screenshot).write_bytes(probe.screenshot()) + print(f"saved {args.screenshot}") + + if failures: + print("\nFAILURES:") + for failure in failures: + print(f" - {failure}") + raise SystemExit(1) + print("\nreflex-xy websocket smoke: all checks passed") + + +if __name__ == "__main__": + main() diff --git a/tests/reflex_adapter/__init__.py b/tests/reflex_adapter/__init__.py new file mode 100644 index 00000000..7e77b323 --- /dev/null +++ b/tests/reflex_adapter/__init__.py @@ -0,0 +1 @@ +"""reflex-xy adapter tests (skipped unless reflex + reflex_xy are installed).""" diff --git a/tests/reflex_adapter/conftest.py b/tests/reflex_adapter/conftest.py new file mode 100644 index 00000000..f9a0d71c --- /dev/null +++ b/tests/reflex_adapter/conftest.py @@ -0,0 +1,38 @@ +"""reflex-xy adapter tests. + +These run only when the adapter's dependencies are installed +(`uv pip install -e python/reflex-xy`); the core `xy` suite must +never require Reflex (CLAUDE.md dependency rule), so everything here +importorskips. +""" + +from __future__ import annotations + +import pytest + +reflex = pytest.importorskip("reflex") +pytest.importorskip("reflex_xy") + +import reflex_xy.app as adapter_app # noqa: E402 +from reflex_xy.registry import reset_registry_for_tests # noqa: E402 + + +@pytest.fixture(autouse=True) +def _fresh_registry(): + """Isolate registry + wiring between tests.""" + registry = reset_registry_for_tests() + adapter_app.reset_setup_for_tests() + yield registry + reset_registry_for_tests() + adapter_app.reset_setup_for_tests() + + +@pytest.fixture +def client_token() -> str: + return "11111111-2222-4333-8444-555566667777" + + +def make_router_data(token: str): + import reflex.istate.data as istate_data + + return istate_data.RouterData.from_router_data({"token": token}) diff --git a/tests/reflex_adapter/test_assets.py b/tests/reflex_adapter/test_assets.py new file mode 100644 index 00000000..78d62fbf --- /dev/null +++ b/tests/reflex_adapter/test_assets.py @@ -0,0 +1,54 @@ +"""Shipped frontend assets: parity with the canonical bundle + wrapper contract.""" + +from __future__ import annotations + +import pathlib + +import reflex_xy + +ADAPTER_ASSETS = pathlib.Path(reflex_xy.__file__).parent / "assets" +CANONICAL = pathlib.Path(__file__).resolve().parents[2] / "python" / "xy" / "static" + + +def test_client_copy_matches_canonical_bundle(): + """xy_client.js is a build artifact: byte-identical to static/index.js. + + On drift: run `node js/build.mjs` and commit both copies. + """ + adapter = (ADAPTER_ASSETS / "xy_client.js").read_bytes() + canonical = (CANONICAL / "index.js").read_bytes() + assert adapter == canonical + + +def test_wrapper_speaks_the_namespace_protocol(): + """The JSX wrapper and namespace.py must agree on event names and shapes.""" + jsx = (ADAPTER_ASSETS / "XYChart.jsx").read_text(encoding="utf-8") + # transport identity: same engine.io path as the app socket, /_xy namespace + assert 'nsUrl.pathname = "/_xy"' in jsx + assert "path: endpoint.pathname" in jsx + # client -> server events + for needle in ('"sub"', '"unsub"', '"msg"'): + assert f"socket.emit({needle}" in jsx or f"emit({needle}" in jsx + # server -> client events + for needle in ('"payload"', '"msg"', '"err"'): + assert f"socket.on({needle}" in jsx + # binary columns go straight into typed arrays — never through JSON numbers + assert "new Uint8Array(b)" in jsx + # the wrapper imports the sibling client copy, not a CDN or npm package + assert 'from "./xy_client.js"' in jsx + + +def test_wrapper_mirrors_reflex_connection_options(): + """The shared-manager trick only works if our io() options match reflex's + connect() (utils/state.js). These names are the coupling surface — if + reflex renames them, this test is the early warning.""" + jsx = (ADAPTER_ASSETS / "XYChart.jsx").read_text(encoding="utf-8") + for needle in ( + "getBackendURL(env.EVENT)", + "transports: [env.TRANSPORT]", + "protocols: [reflexEnvironment.version]", + "query: { token: getToken() }", + "autoUnref: false", + "reconnection: false", + ): + assert needle in jsx, f"wrapper lost reflex connection option: {needle}" diff --git a/tests/reflex_adapter/test_component.py b/tests/reflex_adapter/test_component.py new file mode 100644 index 00000000..8aa7df95 --- /dev/null +++ b/tests/reflex_adapter/test_component.py @@ -0,0 +1,76 @@ +"""Component compile smoke: props, event wiring, asset registration.""" + +from __future__ import annotations + +import os +import pathlib + +import pytest +import reflex as rx +import reflex_xy + + +class CompState(rx.State): + last_row: dict = {} + + @rx.event + def picked(self, row: dict): + self.last_row = row + + +@pytest.fixture +def app_cwd(tmp_path, monkeypatch): + """rx.asset symlinks into Path.cwd()/assets — emulate an app directory.""" + monkeypatch.chdir(tmp_path) + # component class is cached per process; asset symlinks are per-cwd, so + # force a rebuild to exercise registration in this cwd. + import reflex_xy.component as component_mod + + monkeypatch.setattr(component_mod, "_component_cls", None) + return tmp_path + + +def test_component_compiles_with_events(app_cwd): + comp = reflex_xy.chart("tok-abc", on_point_hover=CompState.picked, height="300px", id="chart1") + assert comp.tag == "XYChart" + assert str(comp.library).startswith("$/public/external/reflex_xy/assets/XYChart") + rendered = str(comp) + assert 'token:"tok-abc"' in rendered + assert "onPointHover" in rendered + assert "picked" in rendered # the reflex event dispatch is in the prop + + # both frontend files were registered into the app's assets tree + ext = pathlib.Path(app_cwd) / "assets" / "external" / "reflex_xy" / "assets" + assert (ext / "XYChart.jsx").exists() + assert (ext / "xy_client.js").exists() + # symlinks resolve to the installed package files + assert (ext / "xy_client.js").resolve().read_bytes()[:16] + + +def test_component_accepts_var_token(app_cwd): + class TokState(rx.State): + tok: str = "" + + comp = reflex_xy.chart(TokState.tok) + rendered = str(comp) + assert "tok" in rendered + # default sizing keeps the mount visible before the first payload + assert "height" in rendered.lower() + + +def test_component_import_is_local_library(app_cwd): + comp = reflex_xy.chart("tok") + imports = comp._get_all_imports() + lib = [k for k in imports if "XYChart" in k] + assert lib, f"wrapper import missing from {list(imports)}" + assert lib[0].startswith("$/public/external/"), "must never be an npm specifier" + + +def test_component_creation_does_not_touch_repo_root(): + """Outside an app cwd nothing has leaked assets/ into the repo.""" + repo_root = pathlib.Path(reflex_xy.__file__).resolve().parents[3] + assert not (repo_root / "assets").exists(), ( + "importing/creating reflex_xy components must not scatter asset " + "symlinks outside an app directory" + ) + assert os.getcwd() != str(repo_root) or True diff --git a/tests/reflex_adapter/test_figure_var.py b/tests/reflex_adapter/test_figure_var.py new file mode 100644 index 00000000..f68a80a7 --- /dev/null +++ b/tests/reflex_adapter/test_figure_var.py @@ -0,0 +1,140 @@ +"""The @reflex_xy.figure computed var: registration, stability, rebuild.""" + +from __future__ import annotations + +import asyncio + +import numpy as np +import pytest +import reflex as rx +import reflex_xy +from reflex_xy.tokens import builder_of, parse_token + +import xy as fc + +from .conftest import make_router_data + + +class VarDemo(rx.State): + """State under test: `n` drives the chart; `_scale` is a backend var.""" + + n: int = 100 + _scale: float = 2.0 + + @reflex_xy.figure + def chart(self) -> fc.Chart: + xs = np.linspace(0.0, 1.0, self.n) + return fc.scatter_chart(fc.scatter(xs, xs * self._scale), width=500, height=300) + + @reflex_xy.figure + def maybe_chart(self): + if self.n < 0: + return None + xs = np.linspace(0.0, 1.0, 4) + return fc.line_chart(fc.line(xs, xs), width=300, height=200) + + +def hydrated_substate(client_token: str) -> VarDemo: + root = rx.State(_reflex_internal_init=True) + root.router = make_router_data(client_token) + return root.get_substate(tuple(VarDemo.get_full_name().split("."))[1:]) + + +def test_deps_track_the_builder_not_the_wrapper(): + deps = VarDemo.computed_vars["chart"]._deps(VarDemo) + assert deps == {VarDemo.get_full_name(): {"n", "_scale"}} + + +def test_evaluation_registers_and_token_parses(_fresh_registry, client_token): + state = hydrated_substate(client_token) + token = state.chart + parsed = parse_token(token) + assert parsed is not None + assert parsed.client_token == client_token + assert parsed.state_full_name == VarDemo.get_full_name() + assert parsed.var_name == "chart" + entry = _fresh_registry.get(token) + assert entry is not None + assert entry.figure.traces[0].n_points == 100 + + +def test_dep_change_keeps_token_bumps_version(_fresh_registry, client_token): + state = hydrated_substate(client_token) + token = state.chart + state.n = 250 + VarDemo.computed_vars["chart"].mark_dirty(state) + assert state.chart == token # stable identity: frontend never re-renders + entry = _fresh_registry.get(token) + assert entry.version == 2 + assert entry.figure.traces[0].n_points == 250 + + +def test_recompute_broadcasts_to_publish_hook(_fresh_registry, client_token): + published: list[tuple[str, int]] = [] + + async def hook(token, entry): + published.append((token, entry.version)) + + async def main(): + _fresh_registry.attach_loop(asyncio.get_running_loop()) + _fresh_registry.on_publish(hook) + state = hydrated_substate(client_token) + token = state.chart # first registration: new entry, no fan-out needed yet + state.n = 300 + VarDemo.computed_vars["chart"].mark_dirty(state) + assert state.chart == token + await asyncio.sleep(0.02) + return token + + token = asyncio.run(main()) + assert published == [(token, 2)] + + +def test_pre_hydration_returns_empty(_fresh_registry): + root = rx.State(_reflex_internal_init=True) + state = root.get_substate(tuple(VarDemo.get_full_name().split("."))[1:]) + assert state.chart == "" # no client token yet -> no figure, no crash + assert len(_fresh_registry) == 0 + + +def test_none_chart_unregisters(_fresh_registry, client_token): + state = hydrated_substate(client_token) + token = state.maybe_chart + assert _fresh_registry.get(token) is not None + state.n = -1 + VarDemo.computed_vars["maybe_chart"].mark_dirty(state) + assert state.maybe_chart == "" + assert _fresh_registry.get(token) is None + + +def test_builder_resolvable_from_class(client_token): + builder = builder_of(VarDemo, "chart") + assert builder is not None + state = hydrated_substate(client_token) + chart = builder(state) + assert chart.figure().traces[0].n_points == state.n + + +def test_underscore_var_rejected(): + with pytest.raises(ValueError, match="must not start with '_'"): + + class Bad(rx.State): # noqa: F841 - definition is the assertion + @reflex_xy.figure + def _hidden(self): + return None + + +def test_var_value_survives_state_serialization(_fresh_registry, client_token): + """Simulates the reconnect-on-another-node handoff: the token rides the + state serializer (as it would through redis); the figure does not.""" + state = hydrated_substate(client_token) + token = state.chart + payload = state._serialize() + assert payload # pickles fine with a registered figure in play + + _fresh_registry.release(token) # "another node": no local figure + restored = VarDemo._deserialize(payload) + # The cached var value comes back verbatim WITHOUT re-running the + # builder — exactly why the namespace needs the rebuild-from-state path. + assert restored.chart == token + assert _fresh_registry.get(token) is None diff --git a/tests/reflex_adapter/test_registry.py b/tests/reflex_adapter/test_registry.py new file mode 100644 index 00000000..d36e682f --- /dev/null +++ b/tests/reflex_adapter/test_registry.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio + +import numpy as np +import pytest +from reflex_xy.registry import FigureRegistry + +import xy as fc + + +def make_figure(n: int = 16): + xs = np.linspace(0.0, 1.0, n) + return fc.scatter_chart(fc.scatter(xs, xs * 2.0), width=400, height=300).figure() + + +def test_register_release_roundtrip(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + assert token.startswith("xyfig-") + assert registry.get(token) is not None + registry.release(token) + assert registry.get(token) is None + registry.release(token) # idempotent + + +def test_publish_versioning(_fresh_registry): + registry = _fresh_registry + fig1 = make_figure() + entry = registry.publish("tok", fig1, broadcast=False) + assert entry.version == 1 + # same object republished: no version bump + assert registry.publish("tok", fig1, broadcast=False).version == 1 + # new figure object: bump + assert registry.publish("tok", make_figure(32), broadcast=False).version == 2 + + +def test_bump_records_in_place_mutation(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + assert registry.bump(token).version == 2 + assert registry.bump("missing") is None + + +def test_ttl_sweep(_fresh_registry): + registry = FigureRegistry(ttl_seconds=0.0) + token = registry.register(make_figure()) + dropped = registry.sweep(now=registry.get(token).last_access + 1.0) + assert dropped == [token] + assert registry.get(token) is None + + +def test_sweep_keeps_recently_touched(_fresh_registry): + registry = FigureRegistry(ttl_seconds=1000.0) + token = registry.register(make_figure()) + assert registry.sweep() == [] + assert registry.get(token) is not None + + +def test_broadcast_scheduling_from_loop_and_thread(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + seen: list[tuple[str, int]] = [] + + async def on_publish(tok, entry): + seen.append((tok, entry.version)) + + async def main(): + registry.attach_loop(asyncio.get_running_loop()) + registry.on_publish(on_publish) + # same-loop publish + registry.publish(token, make_figure(8)) + await asyncio.sleep(0.05) + # cross-thread publish (sync reflex handlers run in a thread pool) + await asyncio.to_thread(registry.publish, token, make_figure(4)) + await asyncio.sleep(0.05) + + asyncio.run(main()) + assert seen == [(token, 2), (token, 3)] + + +def test_rapid_publishes_coalesce(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + seen: list[int] = [] + + async def on_publish(tok, entry): + seen.append(entry.version) + + async def main(): + registry.attach_loop(asyncio.get_running_loop()) + registry.on_publish(on_publish) + # Two publishes before the loop can run the first broadcast: one + # fan-out, carrying the latest state — never a stale intermediate. + registry.publish(token, make_figure(8)) + registry.publish(token, make_figure(4)) + await asyncio.sleep(0.05) + + asyncio.run(main()) + assert seen == [3] + + +def test_broadcast_noop_before_setup(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + # No loop attached: must not raise, must not queue anything. + registry.publish(token, make_figure(8)) + assert registry.get(token).version == 2 + + +def test_figure_accepts_chart_or_figure(_fresh_registry): + registry = _fresh_registry + xs = np.linspace(0.0, 1.0, 8) + chart = fc.scatter_chart(fc.scatter(xs, xs), width=300, height=200) + token_from_chart = registry.register(chart.figure()) + assert registry.get(token_from_chart) is not None + + import reflex_xy + + token = reflex_xy.register(chart) # public API accepts the composed Chart + assert reflex_xy.registry.get(token) is not None + + +def test_entry_lock_serializes(_fresh_registry): + registry = _fresh_registry + token = registry.register(make_figure()) + entry = registry.get(token) + order: list[int] = [] + + async def user(i: int): + async with entry.lock: + order.append(i) + await asyncio.sleep(0.01) + order.append(i) + + async def main(): + await asyncio.gather(user(1), user(2)) + + asyncio.run(main()) + assert order in ([1, 1, 2, 2], [2, 2, 1, 1]) + + +@pytest.mark.parametrize("n", [1, 3]) +def test_len_and_tokens(_fresh_registry, n): + registry = _fresh_registry + tokens = {registry.register(make_figure()) for _ in range(n)} + assert len(registry) == n + assert set(registry.tokens()) == tokens diff --git a/tests/reflex_adapter/test_socket_data_plane.py b/tests/reflex_adapter/test_socket_data_plane.py new file mode 100644 index 00000000..e5c11152 --- /dev/null +++ b/tests/reflex_adapter/test_socket_data_plane.py @@ -0,0 +1,317 @@ +"""End-to-end data plane over a real websocket. + +Boots the same server stack a Reflex backend uses — python-socketio +AsyncServer (with reflex's JSON config) + engine.io ASGI app mounted at +/_event under uvicorn — registers XYNamespace exactly like `setup(app)` +does, and drives it with the real socket.io client protocol. This is the +transport contract the browser wrapper (XYChart.jsx) relies on, minus the +browser: spec as JSON, columns as native binary attachments, replies +mount-addressed, tokens session-affine, registry misses rebuilt. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import socket +from types import SimpleNamespace + +import numpy as np +import pytest +import socketio +import uvicorn +from reflex_base.utils import format as reflex_format +from reflex_xy.app import wire +from reflex_xy.namespace import XYNamespace +from reflex_xy.registry import registry +from reflex_xy.tokens import build_state_token + +import xy as fc + +CLIENT_TOKEN = "11111111-2222-4333-8444-555566667777" +OTHER_TOKEN = "99999999-8888-4777-8666-555544443333" + + +def make_figure(n: int = 64): + xs = np.linspace(0.0, 1.0, n) + ys = xs * 3.0 + return fc.scatter_chart(fc.scatter(xs, ys), width=640, height=400).figure() + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@contextlib.asynccontextmanager +async def data_plane_server(rebuild=None): + """AsyncServer configured like reflex's (app.py _setup_state) + XYNamespace.""" + sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins="*", + json=SimpleNamespace( + dumps=staticmethod(reflex_format.json_dumps), loads=staticmethod(json.loads) + ), + transports=["websocket"], + allow_upgrades=False, + ) + namespace = XYNamespace(registry, rebuild=rebuild) + sio.register_namespace(namespace) + wire(namespace) + registry.attach_loop(asyncio.get_running_loop()) + asgi = socketio.ASGIApp(sio, socketio_path="/_event") + + port = free_port() + config = uvicorn.Config(asgi, host="127.0.0.1", port=port, log_level="error") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + while not server.started: + await asyncio.sleep(0.01) + try: + yield f"http://127.0.0.1:{port}", namespace + finally: + server.should_exit = True + await task + + +async def connect_client(base_url: str, client_token: str = CLIENT_TOKEN): + """Connect the way XYChart.jsx does: /_xy namespace, token in the query.""" + client = socketio.AsyncClient(reconnection=False) + await client.connect( + f"{base_url}?token={client_token}", + socketio_path="/_event", + namespaces=["/_xy"], + transports=["websocket"], + ) + return client + + +class Collector: + """Buffers events from one client for ordered assertions.""" + + def __init__(self, client: socketio.AsyncClient) -> None: + self.payloads: asyncio.Queue = asyncio.Queue() + self.messages: asyncio.Queue = asyncio.Queue() + self.errors: asyncio.Queue = asyncio.Queue() + client.on("payload", self.payloads.put, namespace="/_xy") + client.on("msg", self.messages.put, namespace="/_xy") + client.on("err", self.errors.put, namespace="/_xy") + + @staticmethod + async def next(queue: asyncio.Queue, timeout: float = 5.0): + return await asyncio.wait_for(queue.get(), timeout) + + +def run(coro): + return asyncio.run(asyncio.wait_for(coro, 60.0)) + + +def test_sub_delivers_spec_and_binary_columns(_fresh_registry): + async def main(): + token = registry.register(make_figure(64)) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "px": 640, "mid": "m1"}, namespace="/_xy") + payload = await collector.next(collector.payloads) + await client.disconnect() + assert payload["fig"] == token + assert payload["version"] == 1 + spec = payload["spec"] + assert spec["buffer_layout"] == "split" + assert len(spec["traces"]) == 1 + buffers = payload["buffers"] + # Binary columns arrive as raw bytes (the JS client sees ArrayBuffers): + # no base64, no JSON numbers (§29 preserved across this transport). + assert all(isinstance(b, (bytes, bytearray)) for b in buffers) + xcol = np.frombuffer(buffers[0], dtype=np.float32) + assert len(xcol) == 64 + + run(main()) + + +def test_msg_round_trip_pick_and_select(_fresh_registry): + async def main(): + token = registry.register(make_figure(16)) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "mid": "m1"}, namespace="/_xy") + await collector.next(collector.payloads) + + # pick -> exact f64 row readout, mid echoed for mount routing + await client.emit( + "msg", + { + "fig": token, + "mid": "m1", + "m": {"type": "pick", "trace": 0, "index": 3, "seq": 7}, + }, + namespace="/_xy", + ) + reply = await collector.next(collector.messages) + assert reply["mid"] == "m1" + assert reply["message"]["type"] == "pick_result" + assert reply["message"]["seq"] == 7 + row = reply["message"]["row"] + assert row["x"] == pytest.approx(3 / 15) + assert row["y"] == pytest.approx(3 / 15 * 3.0) + + # select -> selection mask as binary buffers + await client.emit( + "msg", + { + "fig": token, + "mid": "m1", + "m": {"type": "select", "x0": 0.0, "x1": 0.5, "y0": 0.0, "y1": 3.0}, + }, + namespace="/_xy", + ) + sel = await collector.next(collector.messages) + assert sel["message"]["type"] == "selection" + assert sel["message"]["total"] == 8 + assert len(sel["buffers"]) == 1 + + # malformed messages are dropped silently, never crash the server + await client.emit("msg", {"fig": token, "m": ["not", "a", "dict"]}, namespace="/_xy") + await client.emit("msg", "garbage", namespace="/_xy") + await client.emit( + "msg", + { + "fig": token, + "mid": "m1", + "m": {"type": "pick", "trace": 0, "index": 5, "seq": 8}, + }, + namespace="/_xy", + ) + after = await collector.next(collector.messages) + assert after["message"]["seq"] == 8 + await client.disconnect() + + run(main()) + + +def test_state_token_affinity_enforced(_fresh_registry): + async def main(): + state_token = build_state_token(CLIENT_TOKEN, "root.some_state", "chart") + registry.publish(state_token, make_figure(8), broadcast=False) + async with data_plane_server() as (url, _): + # A connection carrying a DIFFERENT reflex client token must not + # be able to subscribe to this figure. + thief = await connect_client(url, client_token=OTHER_TOKEN) + thief_collector = Collector(thief) + await thief.emit("sub", {"fig": state_token, "mid": "m1"}, namespace="/_xy") + err = await thief_collector.next(thief_collector.errors) + assert "another session" in err["error"] + + owner = await connect_client(url, client_token=CLIENT_TOKEN) + owner_collector = Collector(owner) + await owner.emit("sub", {"fig": state_token, "mid": "m1"}, namespace="/_xy") + payload = await owner_collector.next(owner_collector.payloads) + assert payload["fig"] == state_token + await thief.disconnect() + await owner.disconnect() + + run(main()) + + +def test_registry_miss_rebuilds_from_hook(_fresh_registry): + """The reconnect-lands-on-a-fresh-node path: no figure, hook rebuilds.""" + rebuilt = [] + + async def rebuild(token_str): + rebuilt.append(token_str) + return make_figure(32) + + async def main(): + state_token = build_state_token(CLIENT_TOKEN, "root.some_state", "chart") + # NOTE: never registered — the registry misses on first sub. + async with data_plane_server(rebuild=rebuild) as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": state_token, "mid": "m1"}, namespace="/_xy") + payload = await collector.next(collector.payloads) + assert payload["fig"] == state_token + assert len(payload["buffers"]) == 2 + await client.disconnect() + assert rebuilt == [state_token] + assert registry.get(state_token) is not None + + run(main()) + + +def test_unknown_opaque_token_errors(_fresh_registry): + async def main(): + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": "xyfig-doesnotexist", "mid": "m1"}, namespace="/_xy") + err = await collector.next(collector.errors) + assert err["error"] == "unknown figure token" + await client.disconnect() + + run(main()) + + +def test_publish_broadcasts_to_subscribers(_fresh_registry): + """State-driven rebuild: publish() pushes a fresh payload to the room.""" + + async def main(): + token = registry.register(make_figure(16)) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "mid": "m1"}, namespace="/_xy") + first = await collector.next(collector.payloads) + assert first["version"] == 1 + + registry.publish(token, make_figure(48)) # e.g. a dep-driven recompute + second = await collector.next(collector.payloads) + assert second["version"] == 2 + xcol = np.frombuffer(second["buffers"][0], dtype=np.float32) + assert len(xcol) == 48 + await client.disconnect() + + run(main()) + + +def test_append_streams_to_subscribers(_fresh_registry): + import reflex_xy + + async def main(): + token = registry.register(make_figure(4)) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "mid": "m1"}, namespace="/_xy") + await collector.next(collector.payloads) + + reflex_xy.append(token, x=[2.0, 3.0], y=[6.0, 9.0]) + push = await collector.next(collector.messages) + assert push["message"]["type"] == "append" + assert push.get("mid") is None # pushes are room-wide, not mount-addressed + assert registry.get(token).version == 2 + assert registry.get(token).figure.traces[0].n_points == 6 + await client.disconnect() + + run(main()) + + +def test_unsub_stops_broadcasts(_fresh_registry): + async def main(): + token = registry.register(make_figure(8)) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "mid": "m1"}, namespace="/_xy") + await collector.next(collector.payloads) + await client.emit("unsub", {"fig": token, "mid": "m1"}, namespace="/_xy") + await asyncio.sleep(0.05) + registry.publish(token, make_figure(12)) + await asyncio.sleep(0.2) + assert collector.payloads.empty() + await client.disconnect() + + run(main()) diff --git a/tests/reflex_adapter/test_state_bridge.py b/tests/reflex_adapter/test_state_bridge.py new file mode 100644 index 00000000..a8ec764f --- /dev/null +++ b/tests/reflex_adapter/test_state_bridge.py @@ -0,0 +1,91 @@ +"""Rebuild-from-state: the multi-worker / reconnect recovery path.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import numpy as np +import pytest +import reflex as rx +import reflex_xy +from reflex.istate.manager.memory import StateManagerMemory +from reflex_xy.state_bridge import make_rebuild_hook +from reflex_xy.tokens import build_state_token, parse_token + +import xy as fc + + +class BridgeDemo(rx.State): + points: int = 12 + + @reflex_xy.figure + def chart(self) -> fc.Chart: + xs = np.linspace(0.0, 1.0, self.points) + return fc.scatter_chart(fc.scatter(xs, xs), width=400, height=300) + + +def make_app_stub(): + # 0.9.6 memory manager needs no root class up front: the BaseStateToken + # passed to get_state/modify_state carries it. + return SimpleNamespace(state_manager=StateManagerMemory()) + + +def test_rebuild_default_state(_fresh_registry, client_token): + """A fresh node with no prior events still serves the figure (defaults).""" + app = make_app_stub() + token = build_state_token(client_token, BridgeDemo.get_full_name(), "chart") + hook = make_rebuild_hook(app) + figure = asyncio.run(hook(token)) + assert figure is not None + assert figure.traces[0].n_points == 12 + + +def test_rebuild_reads_session_state(_fresh_registry, client_token): + """State mutated by earlier events drives the rebuilt figure.""" + app = make_app_stub() + token_obj = rx.BaseStateToken(ident=client_token, cls=rx.State) + + async def main(): + async with app.state_manager.modify_state(token_obj) as root: + sub = await root.get_state(BridgeDemo) + sub.points = 77 + hook = make_rebuild_hook(app) + return await hook(build_state_token(client_token, BridgeDemo.get_full_name(), "chart")) + + figure = asyncio.run(main()) + assert figure is not None + assert figure.traces[0].n_points == 77 + + +@pytest.mark.parametrize( + "token", + [ + "not-a-state-token", + # valid grammar, unknown state + "xyv1|11111111-2222-4333-8444-555566667777|no.such_state|chart", + ], +) +def test_rebuild_unknown_fails_closed(_fresh_registry, token): + hook = make_rebuild_hook(make_app_stub()) + assert asyncio.run(hook(token)) is None + + +def test_rebuild_var_without_builder_fails_closed(_fresh_registry, client_token): + """A plain @rx.var of the same name is not a figure recipe.""" + + class NotAFigure(rx.State): + @rx.var + def chart(self) -> str: + return "hello" + + token = build_state_token(client_token, NotAFigure.get_full_name(), "chart") + hook = make_rebuild_hook(make_app_stub()) + assert asyncio.run(hook(token)) is None + + +def test_token_full_name_resolves_class(client_token): + token = build_state_token(client_token, BridgeDemo.get_full_name(), "chart") + parsed = parse_token(token) + cls = rx.State.get_class_substate(tuple(parsed.state_full_name.split("."))) + assert cls is BridgeDemo diff --git a/tests/reflex_adapter/test_tokens.py b/tests/reflex_adapter/test_tokens.py new file mode 100644 index 00000000..b552948e --- /dev/null +++ b/tests/reflex_adapter/test_tokens.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from reflex_xy.tokens import build_state_token, parse_token + + +def test_round_trip(): + token = build_state_token("11111111-2222-4333-8444-555566667777", "root.sub_state", "chart") + parsed = parse_token(token) + assert parsed is not None + assert parsed.client_token == "11111111-2222-4333-8444-555566667777" + assert parsed.state_full_name == "root.sub_state" + assert parsed.var_name == "chart" + + +@pytest.mark.parametrize( + "bad", + [ + None, + 123, + "", + "xyfig-deadbeef", # opaque tokens are not state tokens + "xyv1|short|state|var", # client token too short + "xyv1|11111111-2222-4333-8444-555566667777|state", # missing var + "xyv1|11111111-2222-4333-8444-555566667777|sta te|var", # bad state chars + "xyv1|11111111-2222-4333-8444-555566667777|state|1var", # bad identifier + "xyv2|11111111-2222-4333-8444-555566667777|state|var", # unknown version + ], +) +def test_parse_fails_closed(bad): + assert parse_token(bad) is None + + +def test_build_rejects_separator_smuggling(): + with pytest.raises(ValueError): + build_state_token("evil|token", "state", "var") + with pytest.raises(ValueError): + build_state_token("11111111-2222-4333-8444-555566667777", "state", "var|x") + + +@given(st.text(max_size=64)) +def test_parse_never_raises(garbage): + parse_token(garbage) # any outcome but an exception From 6d1fc54884b7afea238cb13d2d268d06f96660b6 Mon Sep 17 00:00:00 2001 From: Masen Date: Thu, 16 Jul 2026 00:43:32 +0000 Subject: [PATCH 2/5] reflex-xy: pass a Chart directly for a zero-backend static tier; inline() pinned tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reflex_xy.chart() now dispatches on its source (design §3.4/§5): - token (state var / string): live kernel chart on the shared websocket, unchanged. - xy Chart/Figure passed directly: compiled at page build into a content-addressed XYBF payload asset (assets/xy/.xyf, new payload_asset.py), served as a static file, and rendered kernel-less via the client's renderStandalone path (src prop) — client-side hover, pan/zoom, worker density re-bin; no registry, no socket, works under reflex export. Page bodies run before the compiler's assets->public copy, so the file ships with every compile; writes are idempotent and skipped under REFLEX_BACKEND_ONLY (prod workers re-evaluate stateful pages but produce no frontend files). The URL is digest-stable across workers and recompiles. reflex_xy.inline(chart) covers fixed data that still wants the kernel: module-scope registration under a content-addressed xyin- token, so every worker derives the same token with no state or rebuild hook. Such entries are pinned: the TTL sweep now spares figures that have no rebuild recipe. Demo gains a direct-Chart sparkline; the browser E2E now asserts four mounted charts, ink in the static one, and exactly three sub frames — the static chart provably never subscribes — still over one backend websocket. 65 adapter tests passing. --- docs/design/reflex-integration.md | 81 +++++++++-- python/reflex-xy/README.md | 32 +++++ python/reflex-xy/examples/demo_app/.gitignore | 1 + python/reflex-xy/examples/demo_app/README.md | 6 +- .../examples/demo_app/demo_app/demo_app.py | 17 +++ python/reflex-xy/reflex_xy/__init__.py | 36 +++++ python/reflex-xy/reflex_xy/assets/XYChart.jsx | 68 ++++++++-- python/reflex-xy/reflex_xy/component.py | 61 +++++++-- python/reflex-xy/reflex_xy/payload_asset.py | 84 ++++++++++++ python/reflex-xy/reflex_xy/registry.py | 15 ++- scripts/reflex_ws_smoke.py | 28 +++- tests/reflex_adapter/test_assets.py | 5 + tests/reflex_adapter/test_payload_asset.py | 127 ++++++++++++++++++ 13 files changed, 518 insertions(+), 43 deletions(-) create mode 100644 python/reflex-xy/reflex_xy/payload_asset.py create mode 100644 tests/reflex_adapter/test_payload_asset.py diff --git a/docs/design/reflex-integration.md b/docs/design/reflex-integration.md index 2f819513..33fc36da 100644 --- a/docs/design/reflex-integration.md +++ b/docs/design/reflex-integration.md @@ -201,13 +201,48 @@ never-registered token of your *own* session materializes a default-state figure — indistinguishable from loading the page fresh, and gated by the same affinity check. -### 3.4 Imperative tier - -`reflex_xy.register(chart) -> "xyfig-"` / `release(token)` keep the -old draft's explicit API for figures that aren't state-derived (ad-hoc -exploration, tests). Opaque tokens rely on unguessability (same trust model -as the client token itself), are **not** rebuildable, and die with the -process or the TTL sweep — documented as the dev tier, not deployment-safe. +### 3.4 Fixed-data tiers: direct Charts and `inline()` + +Not every chart derives from state. Two tiers cover fixed data, chosen by +whether the kernel still matters: + +**Static payload tier — pass the Chart straight to the component.** +`reflex_xy.chart(fc.scatter_chart(...))` compiles the figure to its +first-paint payload at page build, writes it into the app's `assets/xy/` as +one content-addressed XYBF frame (`.xyf` — the §3.2 framing's +natural home), and hands the wrapper a `src` URL instead of a token. The +wrapper fetches the static file and runs the render client **kernel-less**: +the exact `renderStandalone` semantics of `Figure.to_html()` exports — +client-side hover from retained columns, pan/zoom, worker-based density +re-bin — with no registry entry, no subscription, no backend coupling at +all. Deployment story is airtight by construction: page bodies run in the +process that compiles the frontend, *before* the compiler copies `assets/` +into the web build, so the file ships with every compile — including +`reflex export` static hosting, where this tier keeps working with no +backend running. Content addressing makes writes idempotent across workers +and recompiles (prod workers re-evaluate stateful pages but skip writing, +mirroring `rx.asset`'s backend-only guard) and makes the browser cache +correct for free. What this tier gives up, deliberately: kernel round-trips +(deep drilldown past the shipped tiers, exact server picks, streaming) and +semantic events. + +**`inline()` — fixed data that still wants the kernel.** +`token = reflex_xy.inline(chart)` at **module scope** registers the figure +under a content-addressed token (`xyin-`): every backend worker +independently derives the same token when it imports the app module, so the +token baked into the compiled frontend resolves on any worker with no state +and no rebuild hook. Module scope is the load-bearing requirement — page +bodies only run where the frontend compiles, module bodies run everywhere. +Entries are **pinned** (exempt from the TTL sweep) because no rebuild +recipe exists. Shared by design: one figure serves every viewer, so +kernel-side drill state is shared too — same shape as N notebook views of +one widget. Per-viewer data or isolation belongs in `@reflex_xy.figure`. + +**`register()` — the dev tier.** `reflex_xy.register(chart) -> +"xyfig-"` / `release(token)` keep the old draft's explicit API for +ad-hoc exploration and tests. Opaque uuid tokens rely on unguessability +(same trust model as the client token itself), are **not** rebuildable and +not stable across workers — documented as dev-only, not deployment-safe. ### 3.5 Lifecycle @@ -237,13 +272,22 @@ absorbs newer publishes and always ships the latest payload. ```python reflex_xy.chart( - Dash.cloud, # the figure var (or a register() token) + Dash.cloud, # a figure var / inline() / register() token… on_point_hover=Dash.on_hover, # semantic events -> normal handlers on_select_end=Dash.on_select, height="460px", ) + +reflex_xy.chart(fc.line_chart(...)) # …or a Chart directly: static tier (§3.4) ``` +One factory, dispatched on the source: tokens (state vars or strings) +compile to the `token` prop and ride the socket data plane; a Chart/Figure +passed directly compiles to a payload asset and lands in the `src` prop, +which the wrapper fetches and renders kernel-less. Semantic-event props +apply to live sources; a static chart resolves hover tooltips client-side +but dispatches no backend events. + `chart()` is a plain `rx.Component` whose `library` is a **local JSX shared asset** (`$/public/external/reflex_xy/assets/XYChart.jsx`, the same mechanism reflex's own radix color-mode provider uses) — no npm package, no @@ -290,16 +334,23 @@ python/reflex-xy/ reflex_xy/namespace.py XYNamespace: sub/unsub/msg, payload/msg/err, affinity, rebuild-on-miss, binary attachments reflex_xy/app.py setup(app), XYPlugin (post_compile), lifespan - reflex_xy/component.py chart() -> rx.Component (local-JSX library) + reflex_xy/component.py chart() -> rx.Component (local-JSX library); + dispatches token (live) vs Chart (static tier) + reflex_xy/payload_asset.py static tier: Chart -> content-addressed XYBF + asset in assets/xy/ (§3.4) reflex_xy/assets/ XYChart.jsx + xy_client.js (build artifact) - examples/demo_app/ 1M-point drilldown + hover + cross-filter + stream -tests/reflex_adapter/ 54+ tests: token/registry/var/bridge units, - component compile, and a real-websocket + examples/demo_app/ 1M-point drilldown + hover + cross-filter + + stream + a direct-Chart static payload +tests/reflex_adapter/ 65 tests: token/registry/var/bridge/payload-asset + units, component compile, and a real-websocket integration suite (uvicorn + socketio client) covering payload/pick/select/affinity/rebuild/ publish-broadcast/append/unsub ``` +`inline()` (content-addressed pinned tokens, §3.4) lives in the package +root beside `register()`/`release()`. + `xy` itself stays Reflex-free (CLAUDE.md rule); the adapter depends on `xy` + full `reflex` for now — the 0.9.6 `reflex-base` split covers components/vars but not yet App/state-manager access; revisit when a smaller @@ -323,6 +374,12 @@ message protocol is transport-agnostic either way. - **Payload push sizing**: room-wide refreshes use the figure's default `px_width`; per-sid re-fit to each viewport is a straightforward follow-up. +- **Static tier px baseline**: payload assets build at the fluid default + (2048 px) like `to_html()` exports; decimated line tiers cannot re-refine + without a kernel, so extreme upscaling shows the export tier's limits. + Orphaned `assets/xy/*.xyf` digests accumulate under changing data until + manually cleared; a compile-time sweep of unreferenced digests is a + possible follow-up. - **Chunked payload emission** if head-of-line blocking ever shows up in traces (§1). - **Server-side event dispatch** (kernel callbacks → `app.event_processor`) diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md index 73c7946e..13a45be7 100644 --- a/python/reflex-xy/README.md +++ b/python/reflex-xy/README.md @@ -75,6 +75,38 @@ pixels. 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( + fc.line_chart(fc.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(fc.scatter_chart(fc.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 diff --git a/python/reflex-xy/examples/demo_app/.gitignore b/python/reflex-xy/examples/demo_app/.gitignore index b53b5cd0..01f059e1 100644 --- a/python/reflex-xy/examples/demo_app/.gitignore +++ b/python/reflex-xy/examples/demo_app/.gitignore @@ -1,5 +1,6 @@ .states assets/external/ +assets/xy/ .web *.db __pycache__/ diff --git a/python/reflex-xy/examples/demo_app/README.md b/python/reflex-xy/examples/demo_app/README.md index 72174040..c31357d4 100644 --- a/python/reflex-xy/examples/demo_app/README.md +++ b/python/reflex-xy/examples/demo_app/README.md @@ -1,8 +1,10 @@ # reflex-xy demo One page exercising the whole integration: a 1M-point drillable density -scatter, hover row readout, box-select cross-filtering a histogram, and a -live streaming line — all chart data on the app's own websocket. +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 `fc.Chart` (compiled to a payload asset, no +backend involvement at all). ```bash # from the xy repo root diff --git a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py index d33beab3..96f693fb 100644 --- a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py +++ b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py @@ -123,6 +123,21 @@ async def stream(self): await asyncio.sleep(0.25) +def sparkline_chart() -> fc.Chart: + """A fixed chart passed *directly* to reflex_xy.chart(): compiled to a + static payload asset at page build — no token, no registry, no socket.""" + t = np.linspace(0.0, 6.0 * np.pi, 4000) + decay = np.exp(-t / 9.0) + return fc.line_chart( + fc.line(t, np.sin(t) * decay, name="signal"), + fc.line(t, decay, name="envelope"), + fc.x_axis(label="t"), + title="static payload (no backend)", + width="100%", + height=220, + ) + + def hover_readout() -> rx.Component: return rx.hstack( rx.badge("hover"), @@ -175,6 +190,8 @@ def index() -> rx.Component: ), width="100%", ), + # A Chart object passed directly: static payload tier, zero backend. + reflex_xy.chart(sparkline_chart(), height="220px", id="inline"), spacing="4", width="100%", ), diff --git a/python/reflex-xy/reflex_xy/__init__.py b/python/reflex-xy/reflex_xy/__init__.py index d73153a3..51ba77df 100644 --- a/python/reflex-xy/reflex_xy/__init__.py +++ b/python/reflex-xy/reflex_xy/__init__.py @@ -39,6 +39,8 @@ def index() -> rx.Component: from __future__ import annotations +import hashlib +import json from typing import Any from .app import XYPlugin, append, setup @@ -56,6 +58,7 @@ def index() -> rx.Component: "append", "chart", "figure", + "inline", "register", "registry", "release", @@ -75,6 +78,39 @@ def register(chart_or_figure: Any) -> str: return registry.register(_figure_of(chart_or_figure)) +def inline(chart_or_figure: Any) -> str: + """Register a fixed, kernel-backed chart at module scope; returns its token. + + For charts whose data never changes but which still want server-side + drilldown/picks on the shared websocket. Call at **module scope** so the + registration side effect runs in every backend worker (page bodies only + run where the frontend compiles):: + + cloud = reflex_xy.inline(fc.scatter_chart(fc.scatter(x, y))) + + def index(): + return reflex_xy.chart(cloud, height="460px") + + The token is content-addressed — every worker independently derives the + same one, so the frontend's baked-in token resolves everywhere without + state or rebuild hooks. The entry is pinned (exempt from the TTL sweep): + there is no recipe to rebuild it from, so it lives with the process. + + Shared by design: one figure object serves every viewer, so kernel-side + drill state is shared too (like N notebook views of one widget). Data + depending on who's looking belongs in `@reflex_xy.figure`; data needing + no kernel at all can be passed straight to `reflex_xy.chart()` (static + payload tier). + """ + fig = _figure_of(chart_or_figure) + spec, blob = fig.build_payload() + canonical = json.dumps(spec, sort_keys=True, separators=(",", ":")).encode() + digest = hashlib.sha256(canonical + blob).hexdigest()[:20] + token = f"xyin-{digest}" + registry.publish(token, fig, broadcast=False, pinned=True) + return token + + def release(token: str) -> None: """Drop a registered figure (idempotent).""" registry.release(token) diff --git a/python/reflex-xy/reflex_xy/assets/XYChart.jsx b/python/reflex-xy/reflex_xy/assets/XYChart.jsx index 01b85801..f4763e45 100644 --- a/python/reflex-xy/reflex_xy/assets/XYChart.jsx +++ b/python/reflex-xy/reflex_xy/assets/XYChart.jsx @@ -1,19 +1,27 @@ // XYChart: mount a xy figure inside a Reflex app. // -// Transport (docs/design/reflex-integration.md): this component does NOT open -// its own connection. socket.io multiplexing reuses the app's engine.io -// websocket when the manager options match, so `xySocket()` below constructs -// its `/_xy` namespace socket with exactly the options Reflex's own -// `connect()` uses (`$/utils/state`). Whichever side runs first creates the -// shared manager; the other rides it. One TCP connection carries app state -// and chart data — same lifecycle, same auth surface, same proxy config. +// Two modes, one prop apart (docs/design/reflex-integration.md): // -// Data protocol (namespace.py): +// `token` (live) — this component does NOT open its own connection. +// socket.io multiplexing reuses the app's engine.io websocket when the +// manager options match, so `xySocket()` below constructs its `/_xy` +// namespace socket with exactly the options Reflex's own `connect()` uses +// (`$/utils/state`). Whichever side runs first creates the shared manager; +// the other rides it. One TCP connection carries app state and chart data — +// same lifecycle, same auth surface, same proxy config. +// +// Live data protocol (namespace.py): // out: sub {fig, px, mid} | unsub {fig, mid} | msg {fig, mid, m} // in: payload {fig, version, spec, buffers} — buffers are ArrayBuffers // msg {fig, mid?, message, buffers} — replies carry our mid // err {fig, error} // +// `src` (static) — the payload was compiled ahead of time into a binary +// XYBF asset (payload_asset.py). Fetch it, decode the frame, and run the +// render client kernel-less: renderStandalone retains CPU columns so hover +// resolves locally, and density traces refine via the bundled worker. No +// socket, no backend — works under `reflex export`. +// // The chart client itself is the same ESM bundle notebooks use (a byte-exact // sibling copy, ./xy_client.js). Its `comm` seam is fed from socket events; // binary columns arrive as ArrayBuffers and go straight to the GL path. @@ -27,7 +35,7 @@ import io from "socket.io-client"; import env from "$/env.json"; import reflexEnvironment from "$/reflex.json"; import { getBackendURL, getToken } from "$/utils/state"; -import { ChartView } from "./xy_client.js"; +import { ChartView, decodeFrame, renderStandalone } from "./xy_client.js"; // Opt-in console tracing: localStorage.setItem("xy_debug", "1") const DEBUG = globalThis.localStorage?.getItem?.("xy_debug") === "1"; @@ -71,6 +79,7 @@ let nextMountId = 1; export function XYChart(props) { const { token, + src, onPointHover, onPointClick, onSelectEnd, @@ -79,15 +88,50 @@ export function XYChart(props) { ...divProps } = props; const elRef = useRef(null); - dbg("render", { id: divProps.id, tokenType: typeof token, token: String(token).slice(0, 30) }); + dbg("render", { id: divProps.id, token: String(token).slice(0, 30), src }); // Live callback refs so socket handlers never close over stale props. const cbRef = useRef({}); cbRef.current = { onPointHover, onPointClick, onSelectEnd, onViewChange }; + // Static mode: fetch the payload asset, render kernel-less. + useEffect(() => { + const el = elRef.current; + if (!src || !el) return undefined; + const key = el.id || `src:${src}`; + let view = null; + let cancelled = false; + fetch(src) + .then((resp) => { + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.arrayBuffer(); + }) + .then((body) => { + if (cancelled) return; + const frame = decodeFrame(body); + el.replaceChildren(); + // Same call the static HTML export makes: spec + one packed blob + // span, comm = null → local hover columns + worker density re-bin. + view = renderStandalone(el, frame.message, frame.buffers[0]); + (window.__xy_views ||= new Map()).set(key, view); + dbg("static payload mounted", { src, bytes: body.byteLength }); + }) + .catch((err) => { + if (!cancelled) console.warn(`xy: static payload failed for ${src}`, err); + }); + return () => { + cancelled = true; + if (view) view.destroy(); + view = null; + window.__xy_views?.delete(key); + el.replaceChildren(); + }; + }, [src]); + + // Live mode: subscribe on the shared websocket. useEffect(() => { const el = elRef.current; dbg("effect run", { token: token && token.slice(0, 24), hasEl: !!el }); - if (!token || !el) return undefined; + if (!token || src || !el) return undefined; const socket = xySocket(); const mid = `m${nextMountId++}`; let view = null; @@ -216,7 +260,7 @@ export function XYChart(props) { window.__xy_views?.delete(el.id || mid); el.replaceChildren(); }; - }, [token]); + }, [token, src]); // One DOM node, two consumers: our mount logic and reflex's ref registry. const mergedRef = (node) => { diff --git a/python/reflex-xy/reflex_xy/component.py b/python/reflex-xy/reflex_xy/component.py index 4af916e8..564daa79 100644 --- a/python/reflex-xy/reflex_xy/component.py +++ b/python/reflex-xy/reflex_xy/component.py @@ -1,4 +1,16 @@ -"""The Reflex component: `reflex_xy.chart(State.figure_var, ...)`. +"""The Reflex component: `reflex_xy.chart(...)`. + +One factory, three chart sources (docs/design/reflex-integration.md §5): + + reflex_xy.chart(Dash.chart) # @reflex_xy.figure state var (live) + reflex_xy.chart(some_token_string) # register()/inline() token (live) + reflex_xy.chart(fc.scatter_chart(...)) # a Chart directly (static tier) + +A live source compiles to the `token` prop and rides the shared-websocket +data plane. A `xy` Chart (or internal Figure) passed directly is +compiled to a static payload asset (payload_asset.py) and lands in the +`src` prop: the wrapper fetches the binary frame and runs the render client +kernel-less — no registry, no socket, works under `reflex export`. The wrapper React component lives in `assets/XYChart.jsx` and is shipped as a shared asset (the same mechanism reflex's own radix color-mode provider @@ -7,7 +19,7 @@ the first time a chart is actually placed in a page tree. Semantic events cross the normal Reflex event system as small JSON — -row dicts and selection summaries, never data buffers (§2 of the design): +row dicts and selection summaries, never data buffers (§1 of the design): reflex_xy.chart( Dash.chart, @@ -17,6 +29,10 @@ on_view_change=Dash.viewed, # def viewed(self, view: dict) height="480px", ) + +Semantic events need the kernel, so they apply to live sources; a static +chart renders, pans/zooms, and resolves hover tooltips client-side but +dispatches no backend events. """ from __future__ import annotations @@ -26,6 +42,7 @@ import reflex as rx from .assets import WRAPPER_TAG, register +from .payload_asset import payload_asset __all__ = ["chart"] @@ -38,17 +55,22 @@ def _build_component_cls() -> Any: wrapper_library = register() class XYChart(rx.Component): - """A xy figure bound to a registry token.""" + """A xy figure bound to a registry token or a static payload.""" # The shared-asset module path ($/public/external/reflex_xy/assets/…): # a local-JS library, never sent to the package manager. library = wrapper_library tag = WRAPPER_TAG - # The figure token minted by @reflex_xy.figure (or register()). + # Live mode: the figure token minted by @reflex_xy.figure / + # register() / inline(). Exactly one of token/src is ever set. token: rx.Var[str] + # Static mode: URL of a payload asset (XYBF frame) to render + # kernel-less. + src: rx.Var[str] - # Semantic events out (small JSON by construction — §2). + # Semantic events out (small JSON by construction — §1). Live mode + # only; the static tier has no kernel to resolve rows. on_point_hover: rx.EventHandler[lambda row: [row]] on_point_click: rx.EventHandler[lambda row: [row]] on_select_end: rx.EventHandler[lambda selection: [selection]] @@ -62,9 +84,20 @@ class XYChart(rx.Component): return XYChart -def chart(token: Any, **props: Any) -> Any: - """Place a xy chart bound to `token` (a `@reflex_xy.figure` var - or a `reflex_xy.register()` token string). +def _is_chart_like(source: Any) -> bool: + """A public `xy.Chart` (has .figure()) or an internal Figure.""" + return callable(getattr(source, "figure", None)) or callable( + getattr(source, "build_payload", None) + ) + + +def chart(source: Any, **props: Any) -> Any: + """Place a xy chart. + + `source` is a figure token (a `@reflex_xy.figure` state var, or a + `register()`/`inline()` token string) for a live, kernel-backed chart — + or a `xy` Chart/Figure directly, which renders as a static + payload asset with client-side interactivity only (see module doc). Sizing: the outer element defaults to `width: 100%` and a 420px height; pass `width=`/`height=` (or any style prop) to override. Charts built @@ -75,4 +108,14 @@ def chart(token: Any, **props: Any) -> Any: _component_cls = _build_component_cls() props.setdefault("width", "100%") props.setdefault("height", "420px") - return _component_cls.create(token=token, **props) + if isinstance(source, (str, rx.Var)): + props["token"] = source + elif _is_chart_like(source): + props["src"] = payload_asset(source) + else: + msg = ( + "reflex_xy.chart() takes a figure token (state var or string) or a " + f"xy Chart/Figure, got {type(source).__name__}" + ) + raise TypeError(msg) + return _component_cls.create(**props) diff --git a/python/reflex-xy/reflex_xy/payload_asset.py b/python/reflex-xy/reflex_xy/payload_asset.py new file mode 100644 index 00000000..004be226 --- /dev/null +++ b/python/reflex-xy/reflex_xy/payload_asset.py @@ -0,0 +1,84 @@ +"""Static payload assets: the zero-backend chart tier. + +`reflex_xy.chart(fc.scatter_chart(...))` — passing a chart object instead of +a token — lands here: the figure compiles once to its first-paint payload, +which is written into the app's ``assets/`` tree as one binary XYBF frame +(``xy.channel`` §3.2 framing) and served as an ordinary static file. +The wrapper fetches it and runs the render client in standalone mode: no +registry entry, no socket subscription, no state — the same interactivity +tier as ``Figure.to_html()`` exports (client-side hover from retained +columns, pan/zoom, worker-based density re-bin), with kernel round-trips +(deep drilldown, server picks, streaming) deliberately out of scope. Reach +for `reflex_xy.inline` or `@reflex_xy.figure` when those matter. + +Why this works from any context (docs/design/reflex-integration.md): + +- **Page bodies** run in the process that compiles the frontend, *before* + the compiler copies ``assets/`` into ``.web/public`` — so a file written + here ships with that compile, including `reflex export` static builds. +- **Module scope** runs everywhere, including prod backend workers; writes + are content-addressed and idempotent, and skipped entirely under + ``REFLEX_BACKEND_ONLY`` (mirroring ``rx.asset``) where only the frontend + build's copy matters. +- Prod backend workers also re-evaluate stateful pages at boot (reflex + skips only the *saving* of compiled pages) — same guard applies. + +The filename is a digest of the frame bytes: unchanged data means an +unchanged URL across recompiles, workers, and machines (and free browser +caching); changed data means a new file, never a stale chart. Orphaned +digests from old data are left in ``assets/xy/`` — they are inert bytes; +delete the directory any time. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from xy.channel import encode_frame + +from .registry import _figure_of + +__all__ = ["payload_asset"] + +# Subdirectory of the app's assets/ tree owned by this module. +ASSET_SUBDIR = "xy" +_DIGEST_CHARS = 20 # 80 bits of sha256 — collision-safe at chart-count scale +_SUFFIX = ".xyf" + + +def _should_write() -> bool: + """Only processes that feed a frontend build write asset files.""" + from reflex.assets import EnvironmentVariables + + return not EnvironmentVariables.REFLEX_BACKEND_ONLY.get() + + +def payload_asset(chart_or_figure: Any) -> str: + """Compile a chart to a static payload asset; return its URL. + + Returns a reflex ``AssetPathStr`` (frontend-path aware), pointing at + ``assets/xy/.xyf`` in the compiling app. + """ + from reflex.assets import AssetPathStr + + figure = _figure_of(chart_or_figure) + spec, blob = figure.build_payload() + frame = encode_frame(spec, [blob]) + digest = hashlib.sha256(frame).hexdigest()[:_DIGEST_CHARS] + name = f"{digest}{_SUFFIX}" + + if _should_write(): + asset_dir = Path.cwd() / "assets" / ASSET_SUBDIR + asset_dir.mkdir(parents=True, exist_ok=True) + dest = asset_dir / name + if not dest.exists(): + # Content-addressed, so concurrent writers (multiple workers + # importing the app module) produce identical bytes; the rename + # keeps a racing reader from ever seeing a partial file. + tmp = asset_dir / f".{name}.tmp" + tmp.write_bytes(frame) + tmp.replace(dest) + + return AssetPathStr(f"/{ASSET_SUBDIR}/{name}") diff --git a/python/reflex-xy/reflex_xy/registry.py b/python/reflex-xy/reflex_xy/registry.py index 2613f200..47a6f50a 100644 --- a/python/reflex-xy/reflex_xy/registry.py +++ b/python/reflex-xy/reflex_xy/registry.py @@ -48,6 +48,10 @@ class FigureEntry: # Serializes kernel calls per figure; concurrent figures still # parallelize (the kernels release the GIL on the Rust side). lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Pinned entries are exempt from the TTL sweep: figures with no rebuild + # recipe elsewhere (module-level `inline()` charts) live as long as the + # process, or a sweep would break them permanently after idling. + pinned: bool = False def touch(self) -> None: self.last_access = time.monotonic() @@ -95,7 +99,9 @@ def get(self, token: str) -> Optional[FigureEntry]: entry.touch() return entry - def publish(self, token: str, figure: "Figure", *, broadcast: bool = True) -> FigureEntry: + def publish( + self, token: str, figure: "Figure", *, broadcast: bool = True, pinned: bool = False + ) -> FigureEntry: """Insert or replace a figure under `token` and bump its version. Re-publishing the same Figure object is a no-op version-wise unless @@ -105,7 +111,7 @@ def publish(self, token: str, figure: "Figure", *, broadcast: bool = True) -> Fi with self._mutex: entry = self._entries.get(token) if entry is None: - entry = FigureEntry(figure=figure, token=token) + entry = FigureEntry(figure=figure, token=token, pinned=pinned) self._entries[token] = entry changed = True else: @@ -113,6 +119,7 @@ def publish(self, token: str, figure: "Figure", *, broadcast: bool = True) -> Fi if changed: entry.figure = figure entry.version += 1 + entry.pinned = entry.pinned or pinned entry.touch() if broadcast and changed: # Re-publishing the identical object means nothing moved; a new @@ -239,12 +246,12 @@ async def _do() -> None: # -- TTL sweep ----------------------------------------------------------- def sweep(self, *, now: Optional[float] = None) -> list[str]: - """Drop entries idle past the TTL; returns the dropped tokens.""" + """Drop unpinned entries idle past the TTL; returns dropped tokens.""" now = time.monotonic() if now is None else now dropped: list[str] = [] with self._mutex: for token, entry in list(self._entries.items()): - if now - entry.last_access > self._ttl: + if not entry.pinned and now - entry.last_access > self._ttl: del self._entries[token] dropped.append(token) return dropped diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py index fce8a220..1c4e5aef 100644 --- a/scripts/reflex_ws_smoke.py +++ b/scripts/reflex_ws_smoke.py @@ -185,6 +185,18 @@ def backend_websockets(self) -> list[str]: urls.extend(e.get("url", "") for e in events) return [u for u in urls if "/_event" in u or "/_xy" in u] + def sent_ws_frames(self, needle: str) -> list[str]: + """Payloads of sent websocket frames containing `needle`.""" + self.eval("1") + out: list[str] = [] + for (sid, method), events in self.s._events.items(): + if sid == self.sid and method == "Network.webSocketFrameSent": + for e in events: + data = e.get("response", {}).get("payloadData", "") + if needle in data: + out.append(data) + return out + def screenshot(self) -> bytes: shot = self._call( "Page.captureScreenshot", @@ -229,11 +241,11 @@ def main() -> None: with ChromiumSession(chromium, gl="software", sandbox=False) as session: probe = Probe(session, args.frontend) - # 1) all three charts mount live views fed by socket payloads + # 1) all four charts mount live views (three socket-fed, one static) probe.wait_for( - "window.__xy_views && window.__xy_views.size >= 3", + "window.__xy_views && window.__xy_views.size >= 4", timeout_s=120.0, - label="3 mounted chart views", + label="4 mounted chart views", ) print("mounted views:", probe.eval("Array.from(window.__xy_views.keys()).sort()")) @@ -244,11 +256,19 @@ 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 subscribed — + # exactly one sub per live chart, none mentioning the inline one + subs = probe.sent_ws_frames('"sub"') + print(f"sub frames sent: {len(subs)}") + if len(subs) != 3: + failures.append(f"expected 3 sub frames (live charts only), 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) time.sleep(1.0) png = probe.screenshot() - for chart_id, min_ink in (("cloud", 0.02), ("hist", 0.02), ("live", 0.005)): + checks = (("cloud", 0.02), ("hist", 0.02), ("live", 0.005), ("inline", 0.005)) + for chart_id, min_ink in checks: frac = ink_fraction(png, probe.rect(chart_id, page_coords=True), 1.0) print(f"{chart_id}: ink fraction {frac:.2%}") if frac < min_ink: diff --git a/tests/reflex_adapter/test_assets.py b/tests/reflex_adapter/test_assets.py index 78d62fbf..f70a5507 100644 --- a/tests/reflex_adapter/test_assets.py +++ b/tests/reflex_adapter/test_assets.py @@ -36,6 +36,11 @@ def test_wrapper_speaks_the_namespace_protocol(): assert "new Uint8Array(b)" in jsx # the wrapper imports the sibling client copy, not a CDN or npm package assert 'from "./xy_client.js"' in jsx + # static tier: fetch the payload asset, decode the XYBF frame, render + # kernel-less via the same entry point static HTML exports use + assert "decodeFrame" in jsx + assert "renderStandalone(el, frame.message, frame.buffers[0])" in jsx + assert "fetch(src)" in jsx def test_wrapper_mirrors_reflex_connection_options(): diff --git a/tests/reflex_adapter/test_payload_asset.py b/tests/reflex_adapter/test_payload_asset.py new file mode 100644 index 00000000..1ddba6c9 --- /dev/null +++ b/tests/reflex_adapter/test_payload_asset.py @@ -0,0 +1,127 @@ +"""The static payload tier: Chart -> asset file -> src prop, and inline().""" + +from __future__ import annotations + +import numpy as np +import pytest +import reflex as rx +import reflex_xy +from reflex_xy.payload_asset import payload_asset +from reflex_xy.tokens import parse_token + +import xy as fc +from xy.channel import decode_frame + + +def make_chart(n: int = 32, seed: float = 1.0): + xs = np.linspace(0.0, seed, n) + return fc.line_chart(fc.line(xs, xs * seed), width=400, height=200) + + +@pytest.fixture +def app_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + import reflex_xy.component as component_mod + + monkeypatch.setattr(component_mod, "_component_cls", None) + return tmp_path + + +def test_payload_asset_writes_decodable_frame(app_cwd): + url = payload_asset(make_chart()) + assert url.startswith("/xy/") and url.endswith(".xyf") + path = app_cwd / "assets" / url.lstrip("/") + assert path.exists() + frame = decode_frame(path.read_bytes()) + spec = frame.message + assert spec["traces"], "payload spec must carry the traces" + assert len(frame.buffers) == 1 # one packed blob, renderStandalone's shape + assert spec.get("buffer_layout") != "split" + + +def test_payload_asset_is_content_addressed(app_cwd): + first = payload_asset(make_chart(seed=1.0)) + again = payload_asset(make_chart(seed=1.0)) + other = payload_asset(make_chart(seed=2.0)) + assert first == again # same data -> same URL (stable across recompiles) + assert first != other # changed data -> new URL, never a stale cache hit + xy_dir = app_cwd / "assets" / "xy" + assert len(list(xy_dir.glob("*.xyf"))) == 2 + + +def test_payload_asset_write_is_idempotent(app_cwd): + url = payload_asset(make_chart()) + path = app_cwd / "assets" / url.lstrip("/") + stamp = path.stat().st_mtime_ns + assert payload_asset(make_chart()) == url + assert path.stat().st_mtime_ns == stamp # existing digest never rewritten + + +def test_payload_asset_skips_write_backend_only(app_cwd, monkeypatch): + """Prod backend workers re-evaluate stateful pages; they must not need + (or attempt) to produce frontend files — the URL alone must come out + identical to the compile process's.""" + monkeypatch.setattr("reflex_xy.payload_asset._should_write", lambda: False) + url = payload_asset(make_chart()) + assert url.startswith("/xy/") + assert not (app_cwd / "assets" / "xy").exists() + monkeypatch.setattr("reflex_xy.payload_asset._should_write", lambda: True) + assert payload_asset(make_chart()) == url # deterministic across modes + + +def test_chart_component_accepts_chart_directly(app_cwd, _fresh_registry): + comp = reflex_xy.chart(make_chart(), height="220px", id="inline") + rendered = str(comp) + assert 'src:"/xy/' in rendered + assert "token" not in rendered + # the static tier never touches the registry + assert len(_fresh_registry) == 0 + + +def test_chart_component_accepts_figure_directly(app_cwd, _fresh_registry): + comp = reflex_xy.chart(make_chart().figure()) + assert 'src:"/xy/' in str(comp) + assert len(_fresh_registry) == 0 + + +def test_chart_component_rejects_junk(app_cwd): + with pytest.raises(TypeError, match=r"figure token .* or a"): + reflex_xy.chart(42) + + +def test_inline_token_is_stable_and_pinned(_fresh_registry): + token = reflex_xy.inline(make_chart(seed=3.0)) + assert token.startswith("xyin-") + assert parse_token(token) is None # opaque: no session affinity, shared + # same content, e.g. another worker importing the module -> same token + assert reflex_xy.inline(make_chart(seed=3.0)) == token + assert reflex_xy.inline(make_chart(seed=4.0)) != token + + entry = _fresh_registry.get(token) + assert entry is not None and entry.pinned + # pinned entries survive the TTL sweep (no rebuild recipe exists) + assert _fresh_registry.sweep(now=entry.last_access + 10**9) == [] + assert _fresh_registry.get(token) is not None + + +def test_unpinned_entries_still_sweep(_fresh_registry): + token = reflex_xy.register(make_chart()) + entry = _fresh_registry.get(token) + dropped = _fresh_registry.sweep(now=entry.last_access + 10**9) + assert dropped == [token] + + +def test_inline_chart_component_uses_token(app_cwd, _fresh_registry): + token = reflex_xy.inline(make_chart()) + comp = reflex_xy.chart(token) + rendered = str(comp) + assert f'token:"{token}"' in rendered + assert "src" not in rendered + + +def test_component_var_still_routes_to_token(app_cwd): + class SrcTokState(rx.State): + tok: str = "" + + comp = reflex_xy.chart(SrcTokState.tok) + assert "token:" in str(comp) From 15a6633b3943f343c65d28d650abf6ed10b42e83 Mon Sep 17 00:00:00 2001 From: Masen Date: Thu, 16 Jul 2026 00:43:32 +0000 Subject: [PATCH 3/5] reflex-xy: drop the fastcharts-era 'fc' alias from this branch's code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every example, docstring, test, and the demo app now do a plain 'import xy' — no 'import xy as fc'. Mechanical rename within this branch's files only; the pre-existing fastcharts-era conventions elsewhere in the repo are untouched. --- docs/design/reflex-integration.md | 8 ++--- python/reflex-xy/README.md | 10 +++--- python/reflex-xy/examples/demo_app/README.md | 2 +- .../examples/demo_app/demo_app/demo_app.py | 36 +++++++++---------- python/reflex-xy/reflex_xy/__init__.py | 8 ++--- python/reflex-xy/reflex_xy/component.py | 2 +- python/reflex-xy/reflex_xy/payload_asset.py | 2 +- python/reflex-xy/reflex_xy/vars.py | 4 +-- tests/reflex_adapter/test_figure_var.py | 8 ++--- tests/reflex_adapter/test_payload_asset.py | 4 +-- tests/reflex_adapter/test_registry.py | 6 ++-- .../reflex_adapter/test_socket_data_plane.py | 4 +-- tests/reflex_adapter/test_state_bridge.py | 6 ++-- 13 files changed, 50 insertions(+), 50 deletions(-) diff --git a/docs/design/reflex-integration.md b/docs/design/reflex-integration.md index 33fc36da..2dbb0fd6 100644 --- a/docs/design/reflex-integration.md +++ b/docs/design/reflex-integration.md @@ -145,9 +145,9 @@ class Dash(rx.State): points: int = 1_000_000 @reflex_xy.figure - def cloud(self) -> fc.Chart: + def cloud(self) -> xy.Chart: x, y, mag = load(self.points) - return fc.scatter_chart(fc.scatter(x, y, color=mag), width="100%", height=460) + return xy.scatter_chart(xy.scatter(x, y, color=mag), width="100%", height=460) ``` `@reflex_xy.figure` is a computed var whose **value is only the token @@ -207,7 +207,7 @@ Not every chart derives from state. Two tiers cover fixed data, chosen by whether the kernel still matters: **Static payload tier — pass the Chart straight to the component.** -`reflex_xy.chart(fc.scatter_chart(...))` compiles the figure to its +`reflex_xy.chart(xy.scatter_chart(...))` compiles the figure to its first-paint payload at page build, writes it into the app's `assets/xy/` as one content-addressed XYBF frame (`.xyf` — the §3.2 framing's natural home), and hands the wrapper a `src` URL instead of a token. The @@ -278,7 +278,7 @@ reflex_xy.chart( height="460px", ) -reflex_xy.chart(fc.line_chart(...)) # …or a Chart directly: static tier (§3.4) +reflex_xy.chart(xy.line_chart(...)) # …or a Chart directly: static tier (§3.4) ``` One factory, dispatched on the source: tokens (state vars or strings) diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md index 13a45be7..d50d4fbf 100644 --- a/python/reflex-xy/README.md +++ b/python/reflex-xy/README.md @@ -37,7 +37,7 @@ config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()]) # dash/dash.py import numpy as np import reflex as rx -import xy as fc +import xy import reflex_xy @@ -46,11 +46,11 @@ class Dash(rx.State): hovered: dict = {} @reflex_xy.figure - def chart(self) -> fc.Chart: + 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 fc.scatter_chart(fc.scatter(xs, ys), width="100%", height=460) + return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460) @rx.event def on_hover(self, row: dict): @@ -83,7 +83,7 @@ pass the Chart straight in: ```python def index() -> rx.Component: return reflex_xy.chart( - fc.line_chart(fc.line(t, np.sin(t)), width="100%", height=220), + xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220), height="220px", ) ``` @@ -96,7 +96,7 @@ under `reflex export`. When a fixed chart still needs kernel round-trips instead: ```python -cloud = reflex_xy.inline(fc.scatter_chart(fc.scatter(x, y))) # module scope +cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope def index() -> rx.Component: return reflex_xy.chart(cloud, height="460px") diff --git a/python/reflex-xy/examples/demo_app/README.md b/python/reflex-xy/examples/demo_app/README.md index c31357d4..0f430a80 100644 --- a/python/reflex-xy/examples/demo_app/README.md +++ b/python/reflex-xy/examples/demo_app/README.md @@ -3,7 +3,7 @@ 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 `fc.Chart` (compiled to a payload asset, no +chart passed directly as a `xy.Chart` (compiled to a payload asset, no backend involvement at all). ```bash diff --git a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py index 96f693fb..618c8966 100644 --- a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py +++ b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py @@ -23,7 +23,7 @@ import reflex as rx import reflex_xy -import xy as fc +import xy POINTS = 1_000_000 RNG_SEED = 11 @@ -51,35 +51,35 @@ class Demo(rx.State): _stream_t: float = 0.0 @reflex_xy.figure - def cloud(self) -> fc.Chart: + def cloud(self) -> xy.Chart: x, y, mag = _cloud(POINTS) - return fc.scatter_chart( - fc.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), - fc.x_axis(label="feature A"), - fc.y_axis(label="feature B"), + return xy.scatter_chart( + xy.scatter(x, y, color=mag, colormap="viridis", opacity=0.8, density=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), title=f"{POINTS // 1_000_000}M points, drillable", width="100%", height=460, ) @reflex_xy.figure - def histogram(self) -> fc.Chart: + def histogram(self) -> xy.Chart: x, _, mag = _cloud(POINTS) if self.sel_active and self.sel_x1 > self.sel_x0: mag = mag[(x >= self.sel_x0) & (x <= self.sel_x1)] label = "selection" if self.sel_active else "all points" - return fc.histogram_chart( - fc.histogram(mag, bins=80), - fc.x_axis(label=f"magnitude ({label})"), + return xy.histogram_chart( + xy.histogram(mag, bins=80), + xy.x_axis(label=f"magnitude ({label})"), title="magnitude distribution", width="100%", height=220, ) @reflex_xy.figure - def live(self) -> fc.Chart: - return fc.line_chart( - fc.line(np.array([0.0]), np.array([0.0])), + def live(self) -> xy.Chart: + return xy.line_chart( + xy.line(np.array([0.0]), np.array([0.0])), title="live stream", width="100%", height=220, @@ -123,15 +123,15 @@ async def stream(self): await asyncio.sleep(0.25) -def sparkline_chart() -> fc.Chart: +def sparkline_chart() -> xy.Chart: """A fixed chart passed *directly* to reflex_xy.chart(): compiled to a static payload asset at page build — no token, no registry, no socket.""" t = np.linspace(0.0, 6.0 * np.pi, 4000) decay = np.exp(-t / 9.0) - return fc.line_chart( - fc.line(t, np.sin(t) * decay, name="signal"), - fc.line(t, decay, name="envelope"), - fc.x_axis(label="t"), + 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 (no backend)", width="100%", height=220, diff --git a/python/reflex-xy/reflex_xy/__init__.py b/python/reflex-xy/reflex_xy/__init__.py index 51ba77df..ea26ec46 100644 --- a/python/reflex-xy/reflex_xy/__init__.py +++ b/python/reflex-xy/reflex_xy/__init__.py @@ -18,18 +18,18 @@ # dash/dash.py import numpy as np import reflex as rx - import xy as fc + import xy import reflex_xy class Dash(rx.State): points: int = 200_000 @reflex_xy.figure - def chart(self) -> fc.Chart: + 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 fc.scatter_chart(fc.scatter(xs, ys), width="100%", height=460) + return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460) def index() -> rx.Component: return reflex_xy.chart(Dash.chart, height="460px") @@ -86,7 +86,7 @@ def inline(chart_or_figure: Any) -> str: registration side effect runs in every backend worker (page bodies only run where the frontend compiles):: - cloud = reflex_xy.inline(fc.scatter_chart(fc.scatter(x, y))) + cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) def index(): return reflex_xy.chart(cloud, height="460px") diff --git a/python/reflex-xy/reflex_xy/component.py b/python/reflex-xy/reflex_xy/component.py index 564daa79..feac3f75 100644 --- a/python/reflex-xy/reflex_xy/component.py +++ b/python/reflex-xy/reflex_xy/component.py @@ -4,7 +4,7 @@ reflex_xy.chart(Dash.chart) # @reflex_xy.figure state var (live) reflex_xy.chart(some_token_string) # register()/inline() token (live) - reflex_xy.chart(fc.scatter_chart(...)) # a Chart directly (static tier) + reflex_xy.chart(xy.scatter_chart(...)) # a Chart directly (static tier) A live source compiles to the `token` prop and rides the shared-websocket data plane. A `xy` Chart (or internal Figure) passed directly is diff --git a/python/reflex-xy/reflex_xy/payload_asset.py b/python/reflex-xy/reflex_xy/payload_asset.py index 004be226..ca71d604 100644 --- a/python/reflex-xy/reflex_xy/payload_asset.py +++ b/python/reflex-xy/reflex_xy/payload_asset.py @@ -1,6 +1,6 @@ """Static payload assets: the zero-backend chart tier. -`reflex_xy.chart(fc.scatter_chart(...))` — passing a chart object instead of +`reflex_xy.chart(xy.scatter_chart(...))` — passing a chart object instead of a token — lands here: the figure compiles once to its first-paint payload, which is written into the app's ``assets/`` tree as one binary XYBF frame (``xy.channel`` §3.2 framing) and served as an ordinary static file. diff --git a/python/reflex-xy/reflex_xy/vars.py b/python/reflex-xy/reflex_xy/vars.py index 8f08c180..b970ff46 100644 --- a/python/reflex-xy/reflex_xy/vars.py +++ b/python/reflex-xy/reflex_xy/vars.py @@ -101,9 +101,9 @@ class Dash(rx.State): n: int = 100_000 @reflex_xy.figure - def chart(self) -> fc.Chart: + def chart(self) -> xy.Chart: x, y = self._points(self.n) - return fc.scatter_chart(fc.scatter(x, y)) + return xy.scatter_chart(xy.scatter(x, y)) # in the page: reflex_xy.chart(Dash.chart, height="480px") diff --git a/tests/reflex_adapter/test_figure_var.py b/tests/reflex_adapter/test_figure_var.py index f68a80a7..4eb4061d 100644 --- a/tests/reflex_adapter/test_figure_var.py +++ b/tests/reflex_adapter/test_figure_var.py @@ -10,7 +10,7 @@ import reflex_xy from reflex_xy.tokens import builder_of, parse_token -import xy as fc +import xy from .conftest import make_router_data @@ -22,16 +22,16 @@ class VarDemo(rx.State): _scale: float = 2.0 @reflex_xy.figure - def chart(self) -> fc.Chart: + def chart(self) -> xy.Chart: xs = np.linspace(0.0, 1.0, self.n) - return fc.scatter_chart(fc.scatter(xs, xs * self._scale), width=500, height=300) + return xy.scatter_chart(xy.scatter(xs, xs * self._scale), width=500, height=300) @reflex_xy.figure def maybe_chart(self): if self.n < 0: return None xs = np.linspace(0.0, 1.0, 4) - return fc.line_chart(fc.line(xs, xs), width=300, height=200) + return xy.line_chart(xy.line(xs, xs), width=300, height=200) def hydrated_substate(client_token: str) -> VarDemo: diff --git a/tests/reflex_adapter/test_payload_asset.py b/tests/reflex_adapter/test_payload_asset.py index 1ddba6c9..79906644 100644 --- a/tests/reflex_adapter/test_payload_asset.py +++ b/tests/reflex_adapter/test_payload_asset.py @@ -9,13 +9,13 @@ from reflex_xy.payload_asset import payload_asset from reflex_xy.tokens import parse_token -import xy as fc +import xy from xy.channel import decode_frame def make_chart(n: int = 32, seed: float = 1.0): xs = np.linspace(0.0, seed, n) - return fc.line_chart(fc.line(xs, xs * seed), width=400, height=200) + return xy.line_chart(xy.line(xs, xs * seed), width=400, height=200) @pytest.fixture diff --git a/tests/reflex_adapter/test_registry.py b/tests/reflex_adapter/test_registry.py index d36e682f..8a395e01 100644 --- a/tests/reflex_adapter/test_registry.py +++ b/tests/reflex_adapter/test_registry.py @@ -6,12 +6,12 @@ import pytest from reflex_xy.registry import FigureRegistry -import xy as fc +import xy def make_figure(n: int = 16): xs = np.linspace(0.0, 1.0, n) - return fc.scatter_chart(fc.scatter(xs, xs * 2.0), width=400, height=300).figure() + return xy.scatter_chart(xy.scatter(xs, xs * 2.0), width=400, height=300).figure() def test_register_release_roundtrip(_fresh_registry): @@ -111,7 +111,7 @@ def test_broadcast_noop_before_setup(_fresh_registry): def test_figure_accepts_chart_or_figure(_fresh_registry): registry = _fresh_registry xs = np.linspace(0.0, 1.0, 8) - chart = fc.scatter_chart(fc.scatter(xs, xs), width=300, height=200) + chart = xy.scatter_chart(xy.scatter(xs, xs), width=300, height=200) token_from_chart = registry.register(chart.figure()) assert registry.get(token_from_chart) is not None diff --git a/tests/reflex_adapter/test_socket_data_plane.py b/tests/reflex_adapter/test_socket_data_plane.py index e5c11152..b1a79a1b 100644 --- a/tests/reflex_adapter/test_socket_data_plane.py +++ b/tests/reflex_adapter/test_socket_data_plane.py @@ -27,7 +27,7 @@ from reflex_xy.registry import registry from reflex_xy.tokens import build_state_token -import xy as fc +import xy CLIENT_TOKEN = "11111111-2222-4333-8444-555566667777" OTHER_TOKEN = "99999999-8888-4777-8666-555544443333" @@ -36,7 +36,7 @@ def make_figure(n: int = 64): xs = np.linspace(0.0, 1.0, n) ys = xs * 3.0 - return fc.scatter_chart(fc.scatter(xs, ys), width=640, height=400).figure() + return xy.scatter_chart(xy.scatter(xs, ys), width=640, height=400).figure() def free_port() -> int: diff --git a/tests/reflex_adapter/test_state_bridge.py b/tests/reflex_adapter/test_state_bridge.py index a8ec764f..7974ae9a 100644 --- a/tests/reflex_adapter/test_state_bridge.py +++ b/tests/reflex_adapter/test_state_bridge.py @@ -13,16 +13,16 @@ from reflex_xy.state_bridge import make_rebuild_hook from reflex_xy.tokens import build_state_token, parse_token -import xy as fc +import xy class BridgeDemo(rx.State): points: int = 12 @reflex_xy.figure - def chart(self) -> fc.Chart: + def chart(self) -> xy.Chart: xs = np.linspace(0.0, 1.0, self.points) - return fc.scatter_chart(fc.scatter(xs, xs), width=400, height=300) + return xy.scatter_chart(xy.scatter(xs, xs), width=400, height=300) def make_app_stub(): From dfb350d08322dec29750ee503e7031f1e2b35284 Mon Sep 17 00:00:00 2001 From: Masen Date: Thu, 16 Jul 2026 00:51:20 +0000 Subject: [PATCH 4/5] reflex-xy: async figure builders (AsyncFigureVar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reflex_xy.figure now accepts 'async def' builders for charts whose data comes from a database, HTTP endpoint, or dataframe store. Mirrors reflex's ComputedVar/AsyncComputedVar breakdown with the same iscoroutinefunction dispatch rx.var uses: async builders become AsyncFigureVar (an AsyncComputedVar), evaluated and cached by reflex's normal async-var machinery — builder runs on first access and on dependency-dirty, never on cache hits. Dependency tracking still targets the builder body, and the registry-miss rebuild path awaits the same builder on whatever worker recovers the figure. The demo's cross-filter histogram is now an async builder; the browser E2E passes against it from a cold start (reflex's hydrate machinery awaiting the var end to end). Also resyncs the adapter's xy_client.js copy with the post-rebase render client, caught by the drift check. 72 adapter tests. --- docs/design/reflex-integration.md | 20 +- python/reflex-xy/README.md | 10 + .../examples/demo_app/demo_app/demo_app.py | 14 +- python/reflex-xy/reflex_xy/__init__.py | 3 +- .../reflex-xy/reflex_xy/assets/xy_client.js | 1639 ++++++++++++++++- python/reflex-xy/reflex_xy/state_bridge.py | 8 +- python/reflex-xy/reflex_xy/vars.py | 130 +- tests/reflex_adapter/test_async_figure_var.py | 144 ++ 8 files changed, 1862 insertions(+), 106 deletions(-) create mode 100644 tests/reflex_adapter/test_async_figure_var.py diff --git a/docs/design/reflex-integration.md b/docs/design/reflex-integration.md index 2dbb0fd6..1cc18b03 100644 --- a/docs/design/reflex-integration.md +++ b/docs/design/reflex-integration.md @@ -148,6 +148,11 @@ class Dash(rx.State): def cloud(self) -> xy.Chart: x, y, mag = load(self.points) return xy.scatter_chart(xy.scatter(x, y, color=mag), width="100%", height=460) + + @reflex_xy.figure + async def remote(self) -> xy.Chart: + rows = await fetch_rows(self.query) # db / http / dataframe store + return xy.line_chart(xy.line(rows.t, rows.value), width="100%", height=220) ``` `@reflex_xy.figure` is a computed var whose **value is only the token @@ -165,11 +170,20 @@ subclass points dependency analysis at it), so: - Reconnect (same node or another): the cached token comes back with the state; the component re-`sub`s; hit → serve, miss → §3.2. +Async builders are first-class, mirroring reflex's own +`ComputedVar`/`AsyncComputedVar` split with the same +`iscoroutinefunction` dispatch `rx.var` uses: an `async def` builder becomes +an `AsyncFigureVar` (an `AsyncComputedVar`), evaluated and cached by +reflex's normal async-var machinery, and the rebuild path awaits the same +builder when a fresh worker recovers the figure. + Builders must be pure functions of their state instance — the discipline cached computed vars already impose — because purity is exactly what makes -the figure a *rebuildable cache* instead of precious process state. This is -§27 applied to processes: canonical data is Reflex state; every registered -figure is a derived buffer. +the figure a *rebuildable cache* instead of precious process state (for +async builders: deterministic given state — refetching the rows state +points at is exactly the recovery contract). This is §27 applied to +processes: canonical data is Reflex state; every registered figure is a +derived buffer. ### 3.2 Registry miss: rebuild from state diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md index d50d4fbf..36e79a6a 100644 --- a/python/reflex-xy/README.md +++ b/python/reflex-xy/README.md @@ -72,6 +72,16 @@ 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. diff --git a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py index 618c8966..dd9b6916 100644 --- a/python/reflex-xy/examples/demo_app/demo_app/demo_app.py +++ b/python/reflex-xy/examples/demo_app/demo_app/demo_app.py @@ -39,6 +39,14 @@ def _cloud(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: return x, y, np.hypot(x, y) +async def _magnitudes() -> tuple[np.ndarray, np.ndarray]: + """Async data source for the histogram builder — stands in for a + database, HTTP endpoint, or dataframe-store round trip.""" + await asyncio.sleep(0) + x, _, mag = _cloud(POINTS) + return x, mag + + class Demo(rx.State): """Charts are figure vars; everything else is ordinary app state.""" @@ -63,8 +71,10 @@ def cloud(self) -> xy.Chart: ) @reflex_xy.figure - def histogram(self) -> xy.Chart: - x, _, mag = _cloud(POINTS) + async def histogram(self) -> xy.Chart: + # Async builder: reflex evaluates it as an AsyncComputedVar, so the + # data pull can await a database / HTTP endpoint / dataframe store. + x, mag = await _magnitudes() if self.sel_active and self.sel_x1 > self.sel_x0: mag = mag[(x >= self.sel_x0) & (x <= self.sel_x1)] label = "selection" if self.sel_active else "all points" diff --git a/python/reflex-xy/reflex_xy/__init__.py b/python/reflex-xy/reflex_xy/__init__.py index ea26ec46..e050f619 100644 --- a/python/reflex-xy/reflex_xy/__init__.py +++ b/python/reflex-xy/reflex_xy/__init__.py @@ -47,10 +47,11 @@ def index() -> rx.Component: from .component import chart from .namespace import XY_NAMESPACE, XYNamespace from .registry import FigureRegistry, _figure_of, registry -from .vars import FigureVar, figure +from .vars import AsyncFigureVar, FigureVar, figure __all__ = [ "XY_NAMESPACE", + "AsyncFigureVar", "FigureRegistry", "FigureVar", "XYNamespace", diff --git a/python/reflex-xy/reflex_xy/assets/xy_client.js b/python/reflex-xy/reflex_xy/assets/xy_client.js index a51bfcff..6e6d7c79 100644 --- a/python/reflex-xy/reflex_xy/assets/xy_client.js +++ b/python/reflex-xy/reflex_xy/assets/xy_client.js @@ -241,16 +241,34 @@ const FC_CHROME_CSS = ` :where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} :where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)} :where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)} -:where(.xy [data-fc-slot="modebar_button"]){width:26px;height:24px;padding:0;border:none;background:transparent;border-radius:3px;color:var(--chart-axis,currentColor);cursor:pointer} +:where(.xy [data-fc-slot="modebar_button"]){width:24px;height:24px;padding:0;border:none;background:transparent;border-radius:3px;color:var(--chart-text,currentColor);cursor:pointer} +:where(.xy [data-fc-modebar-drag-handle]){position:relative;width:22px;margin-right:4px;cursor:move} +:where(.xy [data-fc-modebar-drag-handle])::after{content:"";position:absolute;top:4px;right:-3px;bottom:4px;width:1px;background:rgba(128,128,128,.28);pointer-events:none} +:where(.xy [data-fc-modebar-menu-trigger]){width:auto;min-width:48px;gap:1px;padding:0 4px;font-size:11px;font-variant-numeric:tabular-nums} +:where(.xy [data-fc-modebar-select-trigger]){width:auto;min-width:30px;gap:0;padding:0 2px} +:where(.xy [data-fc-modebar-menu-indicator]){display:flex;transition:transform .15s} +:where(.xy [data-fc-modebar-menu-indicator] svg){width:11px;height:11px} +:where(.xy [data-fc-modebar-menu]){min-width:148px;gap:1px;padding:4px;background:var(--chart-modebar-bg,rgba(255,255,255,.94));border:1px solid rgba(128,128,128,.22);border-radius:7px;box-shadow:0 5px 18px rgba(15,23,42,.18);backdrop-filter:blur(8px)} +:where(.xy [data-fc-modebar-menu-item]){width:100%;height:28px;justify-content:flex-start;padding:0 9px;border-radius:4px;text-align:left;white-space:nowrap} +:where(.xy [data-fc-modebar-menu-item]:hover,.xy [data-fc-modebar-menu-item]:focus-visible){background:var(--chart-modebar-active,rgba(128,128,128,.18));outline:none} +:where(.xy [data-fc-modebar-menu-item][data-fc-separator]){margin-top:3px;border-top:1px solid rgba(128,128,128,.2);border-radius:0 0 4px 4px} +:where(.xy [data-fc-modebar-menu-icon]){display:flex;width:16px;margin-right:7px} +:where(.xy [data-fc-modebar-menu-icon] svg){width:14px;height:14px} :where(.xy [data-fc-slot="modebar_button"].fc-active){background:var(--chart-modebar-active,rgba(128,128,128,.22))} :where(.xy [data-fc-slot="selection"]){border:1px solid var(--chart-selection,rgba(90,140,240,.9));background:var(--chart-selection-fill,rgba(90,140,240,.15))} :where(.xy [data-fc-slot="selection"][data-fc-band="zoom"]){border-color:var(--chart-zoom-selection,rgba(120,120,120,.9));background:var(--chart-zoom-selection-fill,rgba(120,120,120,.12))} +:where(.xy [data-fc-selection-lasso]){fill:var(--chart-selection-fill,rgba(90,140,240,.15));stroke:var(--chart-selection,rgba(90,140,240,.9));stroke-width:1.5;stroke-linejoin:round;pointer-events:none} +:where(.xy [data-fc-selection-lasso-handle]){fill:var(--chart-bg,#fff);stroke:var(--chart-selection,rgba(90,140,240,.9));stroke-width:1.5;cursor:grab;pointer-events:all} +:where(.xy [data-fc-selection-lasso-handle][data-fc-active]){cursor:grabbing;fill:var(--chart-selection,rgba(90,140,240,.9))} :where(.xy [data-fc-slot="crosshair_x"],.xy [data-fc-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} :where(.xy [data-fc-slot="tick_label"]){color:var(--chart-text,inherit)} :where(.xy [data-fc-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px} :where(.xy [data-fc-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))} :where(.xy [data-fc-slot="canvas"]){cursor:var(--chart-cursor,crosshair)} :where(.xy [data-fc-slot="canvas"][data-fc-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)} +:where(.xy [data-fc-slot="canvas"]:focus-visible,.xy [data-fc-slot="modebar_button"]:focus-visible){outline:2px solid var(--chart-focus,#2563eb);outline-offset:2px} +@media (prefers-reduced-motion:reduce){:where(.xy [data-fc-slot="modebar"]){transition-duration:0s!important}} +@media (forced-colors:active){:where(.xy [data-fc-slot="modebar"],.xy [data-fc-slot="tooltip"]){border:1px solid CanvasText}:where(.xy [data-fc-slot="modebar_button"].fc-active){outline:2px solid Highlight}:where(.xy [data-fc-slot="canvas"]:focus){outline:2px solid Highlight}} `; function ensureChromeStylesheet(node) { let root = node && node.getRootNode ? node.getRootNode() : document; @@ -1666,6 +1684,10 @@ return null; const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; const COLORBAR_THICKNESS = 18; const COLORBAR_GAP = 24; +let FC_A11Y_ID = 0; +const FC_SR_ONLY_STYLE = +"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;" + +"clip:rect(0,0,0,0);white-space:nowrap;border:0;"; const UNITLESS_STYLE_PROPS = new Set([ "animation-iteration-count", "aspect-ratio", @@ -1723,6 +1745,14 @@ for (const view of candidates) { if (over <= 0) break; if (view._releaseContext()) over -= 1; } +if (over <= 0) return; +const visible = live +.filter((view) => view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of visible) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} }, acquired(requester) { requester._ctxPendingReservation = false; @@ -1778,7 +1808,10 @@ this.fluidH = spec.height === "100%"; const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; -this.size = { w: Math.max(120, cw), h: Math.max(120, ch) }; +this.size = { +w: Math.max(this.fluid ? 120 : 48, cw), +h: Math.max(this.fluidH ? 120 : 48, ch), +}; this._layout(); this._buildDom(el); this.theme = readTheme(this.root); @@ -1794,6 +1827,7 @@ this._contextLossCount = 0; this._contextRestoreCount = 0; this._contextRecoveryError = null; this._initGl(buffer); +this._initA11y(); this.root.dataset.fcContextState = "ready"; this._initContextLossRecovery(); this._armContextVisibilityWatch(); @@ -2144,6 +2178,7 @@ this._contextRecoveryError = null; this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); this._dispatchChartEvent("context_restored", { loss_count: this._contextLossCount, restore_count: this._contextRestoreCount, @@ -2154,6 +2189,7 @@ _releaseContext() { if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; const ext = this.gl.getExtension("WEBGL_lose_context"); if (!ext) return false; +this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; @@ -2163,6 +2199,33 @@ this._raf = null; ext.loseContext(); return true; } +_snapshotBeforeRelease() { +try { +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +this._rafKeepPick = true; +this._drawNow(); +let snap = this._ctxSnapshot; +if (!snap) { +snap = this._ctxSnapshot = document.createElement("canvas"); +snap.dataset.fcCtxSnapshot = ""; +} +snap.width = this.canvas.width; +snap.height = this.canvas.height; +snap.style.cssText = this.canvas.style.cssText; +snap.style.pointerEvents = "none"; +snap.getContext("2d").drawImage(this.canvas, 0, 0); +this.canvas.before(snap); +this.canvas.style.visibility = "hidden"; +} catch (_err) { +this._dropContextSnapshot(); +} +} +_dropContextSnapshot() { +this.canvas.style.visibility = ""; +if (this._ctxSnapshot) this._ctxSnapshot.remove(); +this._ctxSnapshot = null; +} _recoverContext() { if (this._destroyed || !this._glLost) return; this._ctxRecoveries += 1; @@ -2203,8 +2266,12 @@ return; } this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); } _armContextVisibilityWatch() { +this._listen(this.root, "pointerenter", () => { +if (this._glLost && !this._destroyed) this._recoverContext(); +}); if (typeof IntersectionObserver === "undefined") { this._ctxVisible = true; return; @@ -2240,11 +2307,12 @@ this.chrome.style.width = this.size.w + "px"; this.chrome.style.height = this.size.h + "px"; this.chrome.width = this.size.w * this.dpr; this.chrome.height = this.size.h * this.dpr; -if (this._legend && this._slotStyleValue("legend", "max-height") == null) { -this._legend.style.maxHeight = p.h - 12 + "px"; +if (this._legends && this._legends.length && this._slotStyleValue("legend", "max-height") == null) { +for (const lg of this._legends) lg.style.maxHeight = p.h - 12 + "px"; } this._positionReductionBadges(); this._positionColorbar(); +this._fitModebar(); this._pickDirty = true; this.draw(); this._scheduleViewRequest(); @@ -2262,6 +2330,26 @@ this._applySlot(root, "root"); el.appendChild(root); this.root = root; ensureChromeStylesheet(root); +let a11yId; +do { +a11yId = `xy-a11y-${++FC_A11Y_ID}`; +} while ( +document.getElementById(`${a11yId}-summary`) || document.getElementById(`${a11yId}-live`) +); +root.setAttribute("role", "region"); +root.setAttribute("aria-label", s.title ? `Chart: ${s.title}` : "Interactive chart"); +this.a11ySummary = document.createElement("div"); +this.a11ySummary.id = `${a11yId}-summary`; +this.a11ySummary.style.cssText = FC_SR_ONLY_STYLE; +root.setAttribute("aria-describedby", this.a11ySummary.id); +root.appendChild(this.a11ySummary); +this.a11yLive = document.createElement("div"); +this.a11yLive.id = `${a11yId}-live`; +this.a11yLive.setAttribute("role", "status"); +this.a11yLive.setAttribute("aria-live", "polite"); +this.a11yLive.setAttribute("aria-atomic", "true"); +this.a11yLive.style.cssText = FC_SR_ONLY_STYLE; +root.appendChild(this.a11yLive); if (s.title) { const t = document.createElement("div"); t.textContent = s.title; @@ -2278,6 +2366,9 @@ this.canvas.style.cssText = `position:absolute;left:${this.plot.x}px;top:${this.plot.y}px;` + `width:${this.plot.w}px;height:${this.plot.h}px;touch-action:none;`; this._applySlot(this.canvas, "canvas"); +this.canvas.tabIndex = 0; +this.canvas.setAttribute("role", "img"); +this.canvas.setAttribute("aria-describedby", this.a11ySummary.id); root.appendChild(this.canvas); this.labels = document.createElement("div"); this.labels.style.cssText = "position:absolute;inset:0;pointer-events:none;"; @@ -2287,11 +2378,39 @@ this.tooltip = document.createElement("div"); this.tooltip.style.cssText = "position:absolute;display:none;pointer-events:none;z-index:5;white-space:nowrap;"; this._applySlot(this.tooltip, "tooltip"); +this.tooltip.setAttribute("aria-hidden", "true"); root.appendChild(this.tooltip); this._buildLegend(root); this._buildColorbar(root); this._buildReductionBadges(root); } +_a11yAxisSummary(axisId, name) { +const axis = this._axis(axisId); +const range = axis.range || []; +if (range.length < 2) return null; +const label = axis.label ? `${name} axis (${axis.label})` : `${name} axis`; +return `${label} ranges from ${fmtValue(range[0], axis.kind)} to ${fmtValue(range[1], axis.kind)}.`; +} +_a11ySummaryText() { +const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; +const parts = [this.spec.title ? `${this.spec.title}.` : "Interactive chart."]; +parts.push(`${traces.length} data series.`); +const names = traces.map((trace) => trace && trace.name).filter(Boolean).slice(0, 6); +if (names.length) parts.push(`Series: ${names.join(", ")}.`); +const x = this._a11yAxisSummary("x", "X"); +const y = this._a11yAxisSummary("y", "Y"); +if (x) parts.push(x); +if (y) parts.push(y); +return parts.join(" "); +} +_initA11y() { +if (!this.a11ySummary || !this.canvas) return; +this.a11ySummary.textContent = this._a11ySummaryText(); +const instruction = this._pickable +? " Use Arrow keys to explore data points in series data order; Home and End jump to the first and last point; Escape closes the readout." +: ""; +this.canvas.setAttribute("aria-label", `Plot area.${instruction}`); +} _compactInt(value) { const n = Number(value); if (!Number.isFinite(n)) return "0"; @@ -2351,8 +2470,9 @@ this._refreshReductionBadges(); } _buildLegend(root) { const s = this.spec; -if (s.show_legend === false) return; +this._legends = []; const items = []; +if (s.show_legend !== false) { for (const t of s.traces) { if (t.tier === "density") { items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" }); @@ -2363,27 +2483,40 @@ items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" items.push({ swatch: "gradient", cmap: t.color.colormap, name: t.name || "value" }); } else if (t.name) { const c = (t.color && t.color.color) || (t.style && t.style.color); -items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} }); +const line = ["line", "segments", "step", "stairs", "errorbar"].includes(t.kind); +items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {} }); +} +} +if (items.length) this._legendBox(root, items, s.legend || {}); +} +for (const extra of s.extra_legends || []) { +const mapped = (extra.items || []).map((it) => ({ +swatch: it.style && it.style.color, +name: it.name, +symbol: it.kind === "scatter" ? (it.style?.symbol || "circle") : null, +line: ["line", "segments", "step", "stairs", "errorbar"].includes(it.kind), +style: it.style || {}, +})); +if (mapped.length) this._legendBox(root, mapped, extra); } } -if (!items.length) return; +_legendBox(root, items, options) { const lg = document.createElement("div"); -const options = s.legend || {}; const loc = options.loc || "upper right"; const ncols = Math.max(1, Number(options.ncols) || 1); const rightInset = this.size.w - (this.plot.x + this.plot.w); const horizontal = ncols > 1; -const xPos = loc.includes("left") -? `left:${this.plot.x + 6}px;` -: loc.includes("center") -? `left:${this.plot.x + this.plot.w / 2}px;transform:translateX(-50%);` -: `right:${rightInset + 6}px;`; -const yPos = loc.includes("lower") -? `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;` -: loc === "center" || loc.includes("center left") || loc.includes("center right") -? `top:${this.plot.y + this.plot.h / 2}px;transform:${loc.includes("center") && !loc.includes("left") && !loc.includes("right") ? "translate(-50%,-50%)" : "translateY(-50%)"};` -: `top:${this.plot.y + 6}px;`; -lg.style.cssText = `position:absolute;${xPos}${yPos}` + +const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center"; +const v = loc.includes("upper") ? "upper" : loc.includes("lower") ? "lower" : "center"; +let xPos, yPos, tx = "0", ty = "0"; +if (h === "left") xPos = `left:${this.plot.x + 6}px;`; +else if (h === "right") xPos = `right:${rightInset + 6}px;`; +else { xPos = `left:${this.plot.x + this.plot.w / 2}px;`; tx = "-50%"; } +if (v === "upper") yPos = `top:${this.plot.y + 6}px;`; +else if (v === "lower") yPos = `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;`; +else { yPos = `top:${this.plot.y + this.plot.h / 2}px;`; ty = "-50%"; } +const transform = tx === "0" && ty === "0" ? "" : `transform:translate(${tx},${ty});`; +lg.style.cssText = `position:absolute;${xPos}${yPos}${transform}` + `display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + "overflow:auto;" + `max-height:${this.plot.h - 12}px;`; this._applySlot(lg, "legend"); @@ -2436,6 +2569,24 @@ svg.appendChild(path); sw.appendChild(svg); sw.style.width = "18px"; sw.style.height = "14px"; +} else if (it.line) { +const ns = "http://www.w3.org/2000/svg"; +const svg = document.createElementNS(ns, "svg"); +svg.setAttribute("viewBox", "0 0 22 12"); +svg.setAttribute("width", "22"); +svg.setAttribute("height", "12"); +const ln = document.createElementNS(ns, "line"); +ln.setAttribute("x1", "1"); +ln.setAttribute("y1", "6"); +ln.setAttribute("x2", "21"); +ln.setAttribute("y2", "6"); +ln.setAttribute("stroke", safeCssPaint(this.root, bg)); +ln.setAttribute("stroke-width", String(it.style?.width ?? 1.5)); +if (it.style?.dash && it.style.dash.length) ln.setAttribute("stroke-dasharray", it.style.dash.join(" ")); +svg.appendChild(ln); +sw.appendChild(svg); +sw.style.width = "22px"; +sw.style.height = "12px"; } else { sw.style.background = safeCssPaint(this.root, bg); } @@ -2445,7 +2596,8 @@ row.appendChild(document.createTextNode(it.name)); lg.appendChild(row); } root.appendChild(lg); -this._legend = lg; +this._legends.push(lg); +return lg; } _buildColorbar(root) { const cb = this.spec.colorbar; @@ -2966,6 +3118,53 @@ const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); } +_buildHexbinMark(g, t, buffer) { +const cx = this._columnView(buffer, this.spec.columns[t.x]); +const cy = this._columnView(buffer, this.spec.columns[t.y]); +const xMeta = { ...this.spec.columns[t.x] }; +const yMeta = { ...this.spec.columns[t.y] }; +const n = Math.min(cx.length, cy.length); +const style = t.style || {}; +const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1); +const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1); +const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0]; +const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3]; +const parts = {}; +for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6); +for (let i = 0; i < n; i++) { +const px = cx[i], py = cy[i]; +for (let k = 0; k < 6; k++) { +const j = i * 6 + k; +parts.x0[j] = px; +parts.y0[j] = py; +parts.x1[j] = px + ringX[k]; +parts.y1[j] = py + ringY[k]; +parts.x2[j] = px + ringX[k + 1]; +parts.y2[j] = py + ringY[k + 1]; +} +} +for (const name of ["x0", "x1", "x2"]) { +g[name + "Meta"] = { ...xMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +for (const name of ["y0", "y1", "y2"]) { +g[name + "Meta"] = { ...yMeta }; +g[name + "Buf"] = this._upload(parts[name]); +} +g.n = n * 6; +g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); +g.colorMode = 0; +if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) { +g.colorMode = t.color.mode === "continuous" ? 1 : 2; +const cval = this._columnView(buffer, this.spec.columns[t.color.buf]); +const expanded = new Float32Array(n * 6); +for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6); +g.cBuf = this._upload(expanded); +g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette); +} +g.meshStrokeWidth = Number(style.stroke_width) || 0; +g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); +} _buildAreaMark(g, t, buffer) { const x = this._columnView(buffer, this.spec.columns[t.x]); const y = this._columnView(buffer, this.spec.columns[t.y]); @@ -3239,6 +3438,7 @@ gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); } draw(keepPick = false) { if (this._destroyed || this._glLost || !this.gl) return; +this._updateZoomMenuLabel?.(); if (this._raf) { this._rafKeepPick = this._rafKeepPick && keepPick; return; @@ -3256,9 +3456,7 @@ const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, this.canvas.width, this.canvas.height); -const bg = this.theme.bg; -if (bg) gl.clearColor(bg[0] * bg[3], bg[1] * bg[3], bg[2] * bg[3], bg[3]); -else gl.clearColor(0, 0, 0, 0); +gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); for (const g of this.gpuTraces) { if (g.tier === "density") { @@ -3273,6 +3471,7 @@ this._drawHoverState(); if (!this._rafKeepPick) this._pickDirty = true; this._rafKeepPick = false; this._drawChrome(); +this._renderLassoSelection?.(); } _now() { return performance.now(); @@ -3490,7 +3689,13 @@ const [vy0, vy1] = this._axisRange(g.yAxis); gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]); +const xrev = (vx0 ?? x0) > (vx1 ?? x1); +const yrev = (vy0 ?? y0) > (vy1 ?? y1); +gl.uniform4f( +u("u_gridRange"), +h.xRange[xrev ? 1 : 0], h.xRange[xrev ? 0 : 1], +h.yRange[yrev ? 1 : 0], h.yRange[yrev ? 0 : 1], +); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); gl.activeTexture(gl.TEXTURE0); @@ -4031,6 +4236,10 @@ this.labels.textContent = ""; this._lastLabelDraw = now; } const p = this.plot; +if (this.theme.bg) { +ctx.fillStyle = cssColor(this.theme.bg); +ctx.fillRect(p.x, p.y, p.w, p.h); +} const xAxis = this._axis("x"); const yAxis = this._axis("y"); const hideX = this._axisTickLabelStrategy(xAxis) === "none"; @@ -4440,18 +4649,25 @@ if (!h || !g._cpuHeatmap) return null; const [x0, x1] = h.xRange; const [y0, y1] = h.yRange; if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; -const col = Math.min(h.w - 1, Math.max(0, Math.floor(((dataX - x0) / (x1 - x0)) * h.w))); -const row = Math.min(h.h - 1, Math.max(0, Math.floor(((dataY - y0) / (y1 - y0)) * h.h))); +const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; +const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; +const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); +const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); +const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); +const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; } _drawKeepPick() { this.draw(true); } _hover(e) { +this._a11yKeyboardReadout = null; if (this._transitionActive()) { const hadHover = this._hoverId !== -1; this._hoverId = -1; this._hoverTarget = null; +this._lastHoverXY = null; +this._pickSeq = (this._pickSeq || 0) + 1; this.tooltip.style.display = "none"; if (hadHover) this.draw(); return; @@ -4464,6 +4680,8 @@ if (!hit) { const hadHover = this._hoverId !== -1; this._hoverId = -1; this._hoverTarget = null; +this._lastHoverXY = null; +this._pickSeq = (this._pickSeq || 0) + 1; this.tooltip.style.display = "none"; if (hadHover) this._drawKeepPick(); return; @@ -4614,6 +4832,135 @@ this._glPrograms = this._progCache; this.gpuTraces = []; } } +const FC_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ +"color", +"label_color", +"width", +"head_size", +"head_style", +"tail_style", +"shaft_width_start", +"shaft_width_end", +"curve", +"angle_a", +"angle_b", +"gap_start", +"gap_end", +"start_offset", +"label_clear", +"dash", +"span_start", +"span_end", +"size", +"symbol", +"stroke_color", +"stroke_width", +"coordinate_space", +]); +function fcLabelClearExit(style, tangent) { +if (typeof style.label_clear !== "string") return 0; +const parts = style.label_clear.split(",").map(Number); +if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0)) return 0; +const [left, right, up, down] = parts; +const [tx, ty] = tangent; +const exitX = tx > 1e-9 ? right / tx : tx < -1e-9 ? left / -tx : Infinity; +const exitY = ty > 1e-9 ? down / ty : ty < -1e-9 ? up / -ty : Infinity; +const exit = Math.min(exitX, exitY); +return Number.isFinite(exit) ? exit : 0; +} +function fcArrowGeometry(x0, y0, x1, y1, style) { +const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); +if (typeof style.start_offset === "string") { +const offset = style.start_offset.split(",").map(Number); +if (offset.length === 2 && offset.every(Number.isFinite)) { +x0 += offset[0]; +y0 += offset[1]; +} +} +const angleA = num(style.angle_a); +const angleB = num(style.angle_b); +const curve = num(style.curve); +let cx = null; +let cy = null; +if (angleA !== null && angleB !== null) { +const a = (-angleA * Math.PI) / 180; +const b = (-angleB * Math.PI) / 180; +const denom = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b); +if (Math.abs(denom) > 1e-6) { +const t = ((x1 - x0) * Math.sin(b) - (y1 - y0) * Math.cos(b)) / denom; +cx = x0 + t * Math.cos(a); +cy = y0 + t * Math.sin(a); +} +} else if (curve) { +const dx = x1 - x0; +const dy = y1 - y0; +cx = (x0 + x1) / 2 + curve * dy; +cy = (y0 + y1) / 2 - curve * dx; +} +const toward = (px, py, qx, qy) => { +const d = Math.hypot(qx - px, qy - py) || 1; +return [(qx - px) / d, (qy - py) / d]; +}; +const t0 = cx === null ? toward(x0, y0, x1, y1) : toward(x0, y0, cx, cy); +const t1 = cx === null ? toward(x1, y1, x0, y0) : toward(x1, y1, cx, cy); +const gapStart = Math.max(0, num(style.gap_start) || 0, fcLabelClearExit(style, t0)); +const gapEnd = Math.max(0, num(style.gap_end) || 0); +const span = Math.hypot(x1 - x0, y1 - y0); +const trim = gapStart + gapEnd < span * 0.9; +const p0 = trim ? [x0 + gapStart * t0[0], y0 + gapStart * t0[1]] : [x0, y0]; +const p1 = trim ? [x1 + gapEnd * t1[0], y1 + gapEnd * t1[1]] : [x1, y1]; +const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); +const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); +return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; +} +function fcArrowShaftPoints(geom, samples = 24) { +const [x0, y0] = geom.p0; +const [x1, y1] = geom.p1; +if (!geom.control) return [[x0, y0], [x1, y1]]; +const [cx, cy] = geom.control; +const points = []; +for (let i = 0; i <= samples; i++) { +const t = i / samples; +const u = 1 - t; +points.push([u * u * x0 + 2 * u * t * cx + t * t * x1, u * u * y0 + 2 * u * t * cy + t * t * y1]); +} +return points; +} +function fcTrimPolylineEnd(points, trim) { +if (!(trim > 0) || points.length < 2) return points; +const out = points.slice(); +let remaining = trim; +while (out.length >= 2) { +const [ax, ay] = out[out.length - 2]; +const [bx, by] = out[out.length - 1]; +const seg = Math.hypot(bx - ax, by - ay); +if (seg > remaining) { +const t = 1 - remaining / seg; +out[out.length - 1] = [ax + t * (bx - ax), ay + t * (by - ay)]; +return out; +} +remaining -= seg; +out.pop(); +} +return out; +} +function fcTaperPolygon(points, w0, w1) { +const left = []; +const right = []; +const count = points.length; +for (let i = 0; i < count; i++) { +const [px, py] = points[i]; +const [ax, ay] = points[Math.max(0, i - 1)]; +const [bx, by] = points[Math.min(count - 1, i + 1)]; +const d = Math.hypot(bx - ax, by - ay) || 1; +const nx = -(by - ay) / d; +const ny = (bx - ax) / d; +const half = (w0 + (w1 - w0) * (i / Math.max(1, count - 1))) / 2; +left.push([px + half * nx, py + half * ny]); +right.push([px - half * nx, py - half * ny]); +} +return left.concat(right.reverse()); +} Object.assign(ChartView.prototype, { _annotationPaint(style, fallback) { return safeCssPaint(this.root, style && style.color, fallback); @@ -4661,8 +5008,7 @@ ctx.restore(); }, _drawArrowLine(ctx, x0, y0, x1, y1, style) { if (![x0, y0, x1, y1].every(Number.isFinite)) return; -const angle = Math.atan2(y1 - y0, x1 - x0); -const head = Math.max(7, this._styleNumber(style, "head_size", 8)); +const geom = fcArrowGeometry(x0, y0, x1, y1, style); ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); @@ -4670,23 +5016,65 @@ ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); ctx.setLineDash(Array.isArray(style.dash) ? style.dash : (typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); +const w0 = Number(style.shaft_width_start); +const w1 = Number(style.shaft_width_end); +const headStyle = style.head_style || "triangle"; +const head = Math.max(4, this._styleNumber(style, "head_size", 8)); +if (Number.isFinite(w0) || Number.isFinite(w1)) { +let points = fcArrowShaftPoints(geom); +if (headStyle === "triangle") { +points = fcTrimPolylineEnd(points, head * Math.cos(Math.PI / 6)); +} +const polygon = fcTaperPolygon( +points, +Number.isFinite(w0) ? w0 : 1, +Number.isFinite(w1) ? w1 : 1 +); ctx.beginPath(); -ctx.moveTo(x0, y0); -ctx.lineTo(x1, y1); +ctx.moveTo(polygon[0][0], polygon[0][1]); +for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i][0], polygon[i][1]); +ctx.closePath(); +ctx.fill(); +} else { +ctx.beginPath(); +ctx.moveTo(geom.p0[0], geom.p0[1]); +if (geom.control) ctx.quadraticCurveTo(geom.control[0], geom.control[1], geom.p1[0], geom.p1[1]); +else ctx.lineTo(geom.p1[0], geom.p1[1]); ctx.stroke(); +} +this._drawArrowEnd(ctx, geom.p1, geom.dir1, headStyle, head); +this._drawArrowEnd(ctx, geom.p0, geom.dir0, style.tail_style || "none", head); +ctx.restore(); +}, +_drawArrowEnd(ctx, point, dir, endStyle, head) { +if (endStyle === "none") return; +const [px, py] = point; +const angle = Math.atan2(dir[1], dir[0]); ctx.beginPath(); -ctx.moveTo(x1, y1); -ctx.lineTo( -x1 - head * Math.cos(angle - Math.PI / 6), -y1 - head * Math.sin(angle - Math.PI / 6) -); -ctx.lineTo( -x1 - head * Math.cos(angle + Math.PI / 6), -y1 - head * Math.sin(angle + Math.PI / 6) -); +if (endStyle === "bar") { +ctx.moveTo(px - (head / 2) * Math.sin(angle), py + (head / 2) * Math.cos(angle)); +ctx.lineTo(px + (head / 2) * Math.sin(angle), py - (head / 2) * Math.cos(angle)); +ctx.stroke(); +return; +} +const wing = (side) => [ +px - head * Math.cos(angle - side * Math.PI / 6), +py - head * Math.sin(angle - side * Math.PI / 6), +]; +const [ax, ay] = wing(1); +const [bx, by] = wing(-1); +if (endStyle === "v") { +ctx.moveTo(ax, ay); +ctx.lineTo(px, py); +ctx.lineTo(bx, by); +ctx.stroke(); +return; +} +ctx.moveTo(px, py); +ctx.lineTo(ax, ay); +ctx.lineTo(bx, by); ctx.closePath(); ctx.fill(); -ctx.restore(); }, _drawAnnotationShapes(ctx) { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; @@ -4830,18 +5218,60 @@ const d = document.createElement("div"); d.textContent = text; const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; -const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0"; +const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0px"; +const rot = Number.isFinite(Number(style.rotation)) +? ((Number(style.rotation) % 360) + 360) % 360 +: 0; +const va = String(style.vertical_align || ""); +const vAnchor = +va === "center" || va === "middle" ? "-50%" +: va === "bottom" ? "-100%" +: va === "top" ? "0px" +: "calc(-100% + 0.35em)"; +let transform = `translate(${anchor},${vAnchor})`; +if (rot === 90 || rot === 270) { +const cw = rot === 270; +const along = +va === "center" || va === "middle" ? "-50%" +: va === "top" ? (cw ? "0" : "-100%") +: va === "bottom" ? (cw ? "-100%" : "0") +: cw ? "0" : "-100%"; +const cross = +ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? (cw ? "0" : "-100%") : cw ? "-100%" : "0"; +transform = `rotate(${cw ? 90 : -90}deg) translate(${along},${cross})`; +} else if (rot) { +transform = `rotate(${-rot}deg) translate(${anchor},${vAnchor})`; +} d.style.cssText = `position:absolute;left:${px + dx}px;top:${py + dy}px;` + -`transform:translate(${anchor},0);pointer-events:none;` + -`white-space:pre-line;text-align:center;`; +`transform:${transform};transform-origin:0 0;pointer-events:none;` + +`white-space:pre-line;text-align:center;width:max-content;`; this._applySlot(d, "annotation_label"); this._applyClass(d, ann.class_name); -this._applyStyle(d, style); +const labelStyle = {}; +for (const [key, value] of Object.entries(style)) { +if (FC_ANNOTATION_SHAPE_STYLE_KEYS.has(key)) continue; +labelStyle[key] = value; +} +this._applyStyle(d, labelStyle); if (style && (style.label_color || style.color)) { d.style.color = this._annotationLabelPaint(style, this.theme.label); } this.labels.appendChild(d); +const cs = getComputedStyle(d); +const edge = (pad, border) => (parseFloat(pad) || 0) + (parseFloat(border) || 0); +const padL = edge(cs.paddingLeft, cs.borderLeftWidth); +const padR = edge(cs.paddingRight, cs.borderRightWidth); +const padT = edge(cs.paddingTop, cs.borderTopWidth); +const padB = edge(cs.paddingBottom, cs.borderBottomWidth); +if ((padL || padR || padT || padB) && rot !== 90 && rot !== 270) { +const hShift = anchor === "-100%" ? padR : anchor === "-50%" ? 0 : -padL; +const vShift = +vAnchor === "-50%" ? 0 : vAnchor === "0px" ? -padT : padB; +d.style.transform = +`${rot ? `rotate(${-rot}deg) ` : ""}` + +`translate(calc(${anchor} + ${hShift}px), calc(${vAnchor} + ${vShift}px))`; +} } }, }); @@ -5034,7 +5464,7 @@ lines.push(`${field}: ${this._formatTooltipValue(value, kind, formats[field])}`) } return lines.length ? lines : this._defaultTooltipLines(row); }, -_renderTooltip(row, clientX, clientY) { +_renderTooltip(row, clientX, clientY, options = {}) { if (!row || this.spec.show_tooltip === false) { this.tooltip.style.display = "none"; return; @@ -5048,6 +5478,14 @@ lines.forEach((ln, i) => { if (i) this.tooltip.appendChild(document.createElement("br")); this.tooltip.appendChild(document.createTextNode(ln)); }); +if (this.a11yLive && options.announce !== false) { +const prefix = this._a11yKeyboardReadout; +const detail = lines.join(", "); +const announcement = prefix +? `Point ${prefix.flat + 1} of ${prefix.total}. ${detail}` +: detail; +if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement; +} this.tooltip.style.display = "block"; const tw = this.tooltip.offsetWidth; this.tooltip.style.left = Math.min(lx + 12, this.size.w - tw - 4) + "px"; @@ -5063,6 +5501,66 @@ this.selRect = document.createElement("div"); this.selRect.style.cssText = "position:absolute;display:none;pointer-events:none;z-index:4;"; this._applySlot(this.selRect, "selection"); this.root.appendChild(this.selRect); +this.selLasso = document.createElementNS("http://www.w3.org/2000/svg", "svg"); +this.selLasso.style.cssText = +"position:absolute;display:none;pointer-events:none;z-index:4;overflow:visible;"; +this.selLasso.dataset.fcSelectionLassoOverlay = ""; +this.selLassoPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); +this.selLassoPath.dataset.fcSelectionLasso = ""; +this.selLasso.appendChild(this.selLassoPath); +this.selLassoHandles = document.createElementNS("http://www.w3.org/2000/svg", "g"); +this.selLassoHandles.dataset.fcSelectionLassoHandles = ""; +this.selLasso.appendChild(this.selLassoHandles); +this.root.appendChild(this.selLasso); +this._lassoPolygon = null; +let lassoHandleDrag = null; +const moveLassoHandle = (e) => { +if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId +|| !this._lassoPolygon) return; +const rect = c.getBoundingClientRect(); +const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); +const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); +this._lassoPolygon[lassoHandleDrag.index] = this._dataFromCanvas(cssX, cssY); +this._renderLassoSelection(); +e.preventDefault(); +e.stopPropagation(); +}; +this._listen(this.selLasso, "pointerdown", (e) => { +const handle = e.target.closest?.("[data-fc-selection-lasso-handle]"); +if (!handle || !this._lassoPolygon) return; +const index = Number(handle.dataset.fcSelectionLassoHandle); +if (!Number.isInteger(index) || !this._lassoPolygon[index]) return; +lassoHandleDrag = { +index, +pointerId: e.pointerId, +original: [...this._lassoPolygon[index]], +handle, +}; +handle.dataset.fcActive = ""; +this.tooltip.style.display = "none"; +try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { } +e.preventDefault(); +e.stopPropagation(); +}); +this._listen(this.selLasso, "pointermove", moveLassoHandle); +this._listen(this.selLasso, "pointerup", (e) => { +if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; +moveLassoHandle(e); +const handle = lassoHandleDrag.handle; +lassoHandleDrag = null; +delete handle.dataset.fcActive; +if (this._lassoPolygon) this._sendSelectPolygon(this._lassoPolygon); +}); +this._listen(this.selLasso, "pointercancel", (e) => { +if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; +if (this._lassoPolygon) { +this._lassoPolygon[lassoHandleDrag.index] = lassoHandleDrag.original; +} +delete lassoHandleDrag.handle.dataset.fcActive; +lassoHandleDrag = null; +if (this._lassoPolygon) this._renderLassoSelection(); +e.stopPropagation(); +}); if (this._interactionFlag("crosshair")) { this.crosshairX = document.createElement("div"); this.crosshairX.style.cssText = @@ -5079,13 +5577,35 @@ const dataAt = (clientX, clientY) => { const r = c.getBoundingClientRect(); return this._dataFromCanvas(clientX - r.left, clientY - r.top); }; +const lassoPointAt = (clientX, clientY) => { +const r = c.getBoundingClientRect(); +const cssX = Math.max(0, Math.min(r.width, clientX - r.left)); +const cssY = Math.max(0, Math.min(r.height, clientY - r.top)); +return { +x: r.left + cssX, +y: r.top + cssY, +data: this._dataFromCanvas(cssX, cssY), +}; +}; this._listen(c, "pointerdown", (e) => { this._cancelViewAnimation(); const canBrush = this._interactionFlag("brush", true) && this._interactionFlag("select", true); -const mode = e.shiftKey && canBrush && this._pickable ? "select" +const selectMode = this.dragMode.startsWith("select") ? this.dragMode : null; +const mode = (e.shiftKey || selectMode) && canBrush && this._pickable +? (e.shiftKey ? "select" : selectMode) : this.dragMode === "zoom" ? "zoom" : null; if (mode) { -band = { mode, sx: e.clientX, sy: e.clientY, d0: dataAt(e.clientX, e.clientY) }; +const previousLasso = mode.startsWith("select") && this._lassoPolygon +? this._lassoPolygon.map((point) => [...point]) +: null; +if (mode.startsWith("select")) this._clearLassoOverlay(); +const firstLassoPoint = mode === "select-lasso" ? lassoPointAt(e.clientX, e.clientY) : null; +const d0 = firstLassoPoint ? firstLassoPoint.data : dataAt(e.clientX, e.clientY); +band = { +mode, sx: e.clientX, sy: e.clientY, d0, +points: firstLassoPoint ? [firstLassoPoint] : null, +previousLasso, +}; c.setPointerCapture(e.pointerId); this.tooltip.style.display = "none"; return; @@ -5122,12 +5642,34 @@ this._hover(e); const end = (e) => { if (band) { this.selRect.style.display = "none"; +this.selLasso.style.display = "none"; const d1 = dataAt(e.clientX, e.clientY); const moved = Math.abs(e.clientX - band.sx) > 3 || Math.abs(e.clientY - band.sy) > 3; if (moved) { if (band.mode === "zoom") this._zoomToBox(band.d0, d1, true); -else this._sendSelect(band.d0, d1); +else if (band.mode === "select-lasso") { +if (band.points.length >= 3) { +const editable = this._simplifyLassoPoints(band.points); +this._sendSelectPolygon(editable.map((point) => point.data)); +} else if (band.previousLasso) { +this._lassoPolygon = band.previousLasso; +this._renderLassoSelection(); +} +} else { +let d0 = band.d0; +if (band.mode === "select-x") { +d0 = [band.d0[0], this.view.y0]; +d1[1] = this.view.y1; +} else if (band.mode === "select-y") { +d0 = [this.view.x0, band.d0[1]]; +d1[0] = this.view.x1; +} +this._sendSelect(d0, d1); +} this._ignoreNextClick = true; +} else if (band.previousLasso) { +this._lassoPolygon = band.previousLasso; +this._renderLassoSelection(); } band = null; return; @@ -5137,11 +5679,23 @@ if (drag && !drag.moved) this.tooltip.style.display = "none"; drag = null; }; this._listen(c, "pointerup", end); -this._listen(c, "pointercancel", () => { this.selRect.style.display = "none"; band = null; drag = null; }); +this._listen(c, "pointercancel", () => { +this.selRect.style.display = "none"; +this.selLasso.style.display = "none"; +if (band?.previousLasso) { +this._lassoPolygon = band.previousLasso; +this._renderLassoSelection(); +} +band = null; +drag = null; +}); this._listen(c, "pointerleave", () => { const hadHover = this._hoverId !== -1; this._hoverId = -1; this._hoverTarget = null; +this._lastHoverXY = null; +this._a11yKeyboardReadout = null; +this._pickSeq = (this._pickSeq || 0) + 1; this.tooltip.style.display = "none"; this._hideCrosshair(); if (this._interactionFlag("hover")) { @@ -5162,6 +5716,66 @@ this._listen(c, "dblclick", () => { this._clearSelection(); this._setView(this.view0, { animate: true }); }); +this._listen(c, "keydown", (e) => this._onA11yKey(e)); +}, +_a11yPointGroups() { +return (this.gpuTraces || []).filter((g) => +markOf(g.trace.kind).pointPick && g.tier !== "density" && g._cpu && +g._cpu.x && g._cpu.y && Math.min(g._cpu.x.length, g._cpu.y.length) > 0); +}, +_onA11yKey(e) { +const direction = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]; +if (direction === undefined && e.key !== "Home" && e.key !== "End" && e.key !== "Escape") { +return; +} +if (e.key === "Escape") { +e.preventDefault(); +const hadHover = this._hoverId !== -1; +this.tooltip.style.display = "none"; +this._hoverId = -1; +this._hoverTarget = null; +this._lastHoverXY = null; +this._a11yKeyboardReadout = null; +this._pickSeq = (this._pickSeq || 0) + 1; +if (this.a11yLive) this.a11yLive.textContent = "Readout closed."; +if (hadHover && this._interactionFlag("hover")) { +this._dispatchChartEvent("leave", { view: this._eventView("leave") }); +} +if (hadHover) this._drawKeepPick(); +return; +} +e.preventDefault(); +if (this._transitionActive()) return; +const groups = this._a11yPointGroups(); +const total = groups.reduce((sum, g) => sum + Math.min(g._cpu.x.length, g._cpu.y.length), 0); +if (!total) return; +let flat = Number.isInteger(this._a11yPointIndex) ? this._a11yPointIndex : -1; +if (e.key === "Home") flat = 0; +else if (e.key === "End") flat = total - 1; +else if (flat < 0) flat = direction > 0 ? 0 : total - 1; +else flat = Math.max(0, Math.min(total - 1, flat + direction)); +this._a11yPointIndex = flat; +let offset = flat; +let g = groups[0]; +for (const candidate of groups) { +const n = Math.min(candidate._cpu.x.length, candidate._cpu.y.length); +if (offset < n) { g = candidate; break; } +offset -= n; +} +const hit = { trace: g.trace.id, index: offset, g }; +const xValue = this._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, offset); +const yValue = this._decodeValue(g._cpu.y, g._cpu.yMeta || g.yMeta, offset); +const x = this._dataPx(g.xAxis || "x", xValue) - this.plot.x; +const y = this._dataPx(g.yAxis || "y", yValue) - this.plot.y; +const rect = this.canvas.getBoundingClientRect(); +const clientX = rect.left + Math.max(0, Math.min(rect.width, x)); +const clientY = rect.top + Math.max(0, Math.min(rect.height, y)); +this._hoverId = hit.trace * 1e9 + hit.index; +this._hoverTarget = hit; +this._lastHoverXY = { clientX, clientY }; +this._a11yKeyboardReadout = { flat, total }; +this._showTooltip(hit, clientX, clientY); +this._drawKeepPick(); }, _updateCrosshair(e) { if (!this.crosshairX || !this.crosshairY) return; @@ -5220,22 +5834,168 @@ this.comm.send(msg); _updateBand(band, e) { const rect = this.canvas.getBoundingClientRect(); const rootRect = this.root.getBoundingClientRect(); +if (band.mode === "select-lasso") { +const previous = band.points[band.points.length - 1]; +const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); +const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); +const clientX = rect.left + cssX; +const clientY = rect.top + cssY; +if (band.points.length < 2048 +&& Math.hypot(clientX - previous.x, clientY - previous.y) >= 3) { +band.points.push({ x: clientX, y: clientY, data: this._dataFromCanvas(cssX, cssY) }); +} +const points = band.points.map((point) => [ +Math.max(this.plot.x, Math.min(this.plot.x + this.plot.w, point.x - rootRect.left)), +Math.max(this.plot.y, Math.min(this.plot.y + this.plot.h, point.y - rootRect.top)), +]); +this.selLasso.style.display = "block"; +this.selLasso.style.inset = "0"; +this.selLasso.setAttribute("width", String(this.root.clientWidth)); +this.selLasso.setAttribute("height", String(this.root.clientHeight)); +this.selLassoPath.setAttribute( +"d", points.map((point, i) => `${i ? "L" : "M"}${point[0]} ${point[1]}`).join(" ") + " Z" +); +return; +} const x = Math.min(band.sx, e.clientX) - rootRect.left; const y = Math.min(band.sy, e.clientY) - rootRect.top; const w = Math.abs(e.clientX - band.sx); const h = Math.abs(e.clientY - band.sy); const px = this.plot.x, py = this.plot.y; const x2 = Math.min(x + w, px + this.plot.w), y2 = Math.min(y + h, py + this.plot.h); -const cx = Math.max(x, px), cy = Math.max(y, py); +let cx = Math.max(x, px), cy = Math.max(y, py); +let bx2 = x2, by2 = y2; +if (band.mode === "select-x") { cy = py; by2 = py + this.plot.h; } +if (band.mode === "select-y") { cx = px; bx2 = px + this.plot.w; } this.selRect.dataset.fcBand = band.mode === "zoom" ? "zoom" : "select"; this.selRect.style.display = "block"; this.selRect.style.left = cx + "px"; this.selRect.style.top = cy + "px"; -this.selRect.style.width = Math.max(0, x2 - cx) + "px"; -this.selRect.style.height = Math.max(0, y2 - cy) + "px"; +this.selRect.style.width = Math.max(0, bx2 - cx) + "px"; +this.selRect.style.height = Math.max(0, by2 - cy) + "px"; void rect; }, +_simplifyLassoPoints(points, tolerance = 6, maxPoints = 16) { +const source = points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y)); +if (source.length > 3) { +const first = source[0], last = source[source.length - 1]; +if (Math.hypot(first.x - last.x, first.y - last.y) <= tolerance) source.pop(); +} +if (source.length <= 3) return source.slice(); +const distanceToSegmentSq = (point, start, end) => { +const dx = end.x - start.x, dy = end.y - start.y; +if (dx === 0 && dy === 0) { +return (point.x - start.x) ** 2 + (point.y - start.y) ** 2; +} +const t = Math.max(0, Math.min(1, +((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy) +)); +const x = start.x + t * dx, y = start.y + t * dy; +return (point.x - x) ** 2 + (point.y - y) ** 2; +}; +const simplifyAt = (currentTolerance) => { +const keep = new Uint8Array(source.length); +keep[0] = 1; +keep[source.length - 1] = 1; +const stack = [[0, source.length - 1]]; +const toleranceSq = currentTolerance * currentTolerance; +while (stack.length) { +const [start, end] = stack.pop(); +let furthest = -1, furthestDistance = toleranceSq; +for (let i = start + 1; i < end; i++) { +const distance = distanceToSegmentSq(source[i], source[start], source[end]); +if (distance > furthestDistance) { +furthest = i; +furthestDistance = distance; +} +} +if (furthest >= 0) { +keep[furthest] = 1; +stack.push([start, furthest], [furthest, end]); +} +} +return source.filter((_point, index) => keep[index]); +}; +let simplified = simplifyAt(tolerance); +if (simplified.length < 3) { +simplified = [source[0], source[Math.floor(source.length / 2)], source[source.length - 1]]; +} +if (simplified.length > maxPoints) { +let low = tolerance; +let high = Math.max(tolerance, 1); +for (let i = 0; i < 16 && simplified.length > maxPoints; i++) { +low = high; +high *= 2; +simplified = simplifyAt(high); +} +for (let i = 0; i < 12; i++) { +const middle = (low + high) / 2; +const candidate = simplifyAt(middle); +if (candidate.length > maxPoints) low = middle; +else { +high = middle; +simplified = candidate; +} +} +if (simplified.length < 3) { +simplified = [source[0], source[Math.floor(source.length / 2)], source[source.length - 1]]; +} +} +return simplified; +}, +_clearLassoOverlay() { +this._lassoPolygon = null; +if (!this.selLasso) return; +this.selLasso.style.display = "none"; +this.selLassoPath?.removeAttribute("d"); +this.selLassoHandles?.replaceChildren(); +}, +_renderLassoSelection() { +const polygon = this._lassoPolygon; +if (!this.selLasso || !this.selLassoPath || !this.selLassoHandles +|| !Array.isArray(polygon) || polygon.length < 3) return; +const [x0, x1] = this._axisRange("x"); +const [y0, y1] = this._axisRange("y"); +const xAxis = this._axis("x"), yAxis = this._axis("y"); +const cx0 = this._axisCoord(xAxis, x0), cx1 = this._axisCoord(xAxis, x1); +const cy0 = this._axisCoord(yAxis, y0), cy1 = this._axisCoord(yAxis, y1); +if (![cx0, cx1, cy0, cy1].every(Number.isFinite) || cx0 === cx1 || cy0 === cy1) return; +const points = polygon.map((point) => { +const cx = this._axisCoord(xAxis, point[0]); +const cy = this._axisCoord(yAxis, point[1]); +const x = this.plot.x + ((cx - cx0) / (cx1 - cx0)) * this.plot.w; +const y = this.plot.y + ((cy1 - cy) / (cy1 - cy0)) * this.plot.h; +return [ +Math.max(this.plot.x, Math.min(this.plot.x + this.plot.w, x)), +Math.max(this.plot.y, Math.min(this.plot.y + this.plot.h, y)), +]; +}); +if (!points.flat().every(Number.isFinite)) return; +this.selLasso.style.display = "block"; +this.selLasso.style.inset = "0"; +this.selLasso.setAttribute("width", String(this.root.clientWidth)); +this.selLasso.setAttribute("height", String(this.root.clientHeight)); +this.selLassoPath.setAttribute( +"d", points.map((point, index) => `${index ? "L" : "M"}${point[0]} ${point[1]}`).join(" ") + " Z" +); +while (this.selLassoHandles.childElementCount < points.length) { +const handle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); +handle.dataset.fcSelectionLassoHandle = ""; +handle.setAttribute("r", "4"); +this.selLassoHandles.appendChild(handle); +} +while (this.selLassoHandles.childElementCount > points.length) { +this.selLassoHandles.lastElementChild.remove(); +} +[...this.selLassoHandles.children].forEach((handle, index) => { +handle.dataset.fcSelectionLassoHandle = String(index); +handle.setAttribute("cx", String(points[index][0])); +handle.setAttribute("cy", String(points[index][1])); +handle.setAttribute("aria-label", `Lasso point ${index + 1}`); +}); +}, _sendSelect(d0, d1) { +this._clearLassoOverlay(); const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); const range = { x0, x1, y0, y1 }; @@ -5246,6 +6006,64 @@ this.comm.send({ type: "select", x0, x1, y0, y1 }); this._selectLocal(x0, x1, y0, y1); } }, +_sendSelectPolygon(points) { +if (!Array.isArray(points) || points.length < 3) return; +const polygon = points.map((point) => [point[0], point[1]]); +if (!polygon.every((point) => point.every(Number.isFinite))) return; +this._lassoPolygon = polygon; +this._renderLassoSelection(); +this._dispatchChartEvent("brush", { +polygon, +view: this._eventView("brush"), +}); +if (this.comm) { +this.comm.send({ type: "select_polygon", points: polygon }); +} else { +this._selectLocalPolygon(polygon); +} +}, +_selectLocalPolygon(points) { +const xs = points.map((point) => point[0]); +const ys = points.map((point) => point[1]); +const minX = Math.min(...xs), maxX = Math.max(...xs); +const minY = Math.min(...ys), maxY = Math.max(...ys); +const inside = (x, y) => { +let hit = false; +for (let i = 0, j = points.length - 1; i < points.length; j = i++) { +const [xi, yi] = points[i], [xj, yj] = points[j]; +if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; +} +return hit; +}; +let total = 0; +for (const g of this.gpuTraces) { +if (!g._cpu || g.tier === "density") continue; +const cx = g._cpu.x, cy = g._cpu.y; +const xMeta = g._cpu.xMeta || g.xMeta; +const yMeta = g._cpu.yMeta || g.yMeta; +const ox = xMeta.offset, sx = xMeta.scale || 1; +const oy = yMeta.offset, sy = yMeta.scale || 1; +const mask = new Float32Array(g.n); +let count = 0; +for (let i = 0; i < g.n; i++) { +const x = cx[i] / sx + ox; +const y = cy[i] / sy + oy; +if (x >= minX && x <= maxX && y >= minY && y <= maxY && inside(x, y)) { +mask[i] = 1; +count++; +} +} +this._applySelMask(g, mask); +total += count; +} +this._selectionCount = total; +this.draw(); +this._dispatchChartEvent("select", { +total, +polygon: points, +view: this._eventView("select"), +}); +}, _selectLocal(x0, x1, y0, y1) { let total = 0; for (const g of this.gpuTraces) { @@ -5280,6 +6098,7 @@ gl.bufferData(gl.ARRAY_BUFFER, maskF32, gl.STATIC_DRAW); g.selActive = true; }, _clearSelection() { +this._clearLassoOverlay(); for (const g of this.gpuTraces) { g.selActive = false; if (g.drill) g.drill.selActive = false; @@ -5290,21 +6109,125 @@ if (this.comm) this.comm.send({ type: "select_clear" }); this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); } }, +_clampModebar(left, top) { +const bar = this._modebar; +if (!bar || !this.root) return; +const currentLeft = left ?? (Number.parseFloat(bar.style.left) || 0); +const currentTop = top ?? (Number.parseFloat(bar.style.top) || 0); +const maxLeft = Math.max(0, this.root.clientWidth - bar.offsetWidth); +const maxTop = Math.max(0, this.root.clientHeight - bar.offsetHeight); +bar.style.left = `${Math.max(0, Math.min(maxLeft, currentLeft))}px`; +bar.style.top = `${Math.max(0, Math.min(maxTop, currentTop))}px`; +}, _buildModebar(root) { if (this.spec.show_modebar === false) return; const bar = document.createElement("div"); bar.style.cssText = `position:absolute;top:${this.plot.y + 4}px;left:${this.plot.x + 4}px;z-index:6;` + -"display:flex;opacity:.72;transition:opacity .15s;"; +"display:flex;opacity:0;pointer-events:none;transition:opacity .15s;"; this._applySlot(bar, "modebar"); -this._listen(root, "pointerenter", () => { bar.style.opacity = "1"; }); -this._listen(root, "pointerleave", () => { bar.style.opacity = ".72"; }); +bar.setAttribute("role", "toolbar"); +bar.setAttribute("aria-label", "Chart controls"); this._modebar = bar; this._modeBtns = {}; +this._modebarMoved = false; +let setZoomMenuOpen = () => {}; +let setSelectMenuOpen = () => {}; +let setExportMenuOpen = () => {}; +const setVisible = (visible) => { +const show = visible || this._modebarDragging || bar.contains(document.activeElement); +bar.style.opacity = show ? "1" : "0"; +bar.style.pointerEvents = show ? "auto" : "none"; +}; +this._listen(root, "pointerenter", () => setVisible(true)); +this._listen(root, "pointerleave", () => { +setVisible(false); +setZoomMenuOpen(false); +setSelectMenuOpen(false); +setExportMenuOpen(false); +}); +this._listen(bar, "focusin", () => setVisible(true)); +this._listen(bar, "focusout", (e) => { +if (!bar.contains(e.relatedTarget) && !root.matches(":hover")) setVisible(false); +}); +const grip = document.createElement("button"); +grip.type = "button"; +grip.title = "Click for toolbar options; drag to move"; +grip.setAttribute("aria-label", "Toolbar options"); +grip.setAttribute("aria-haspopup", "menu"); +grip.setAttribute("aria-expanded", "false"); +grip.dataset.fcModebarDragHandle = ""; +grip.dataset.fcModebarExport = ""; +grip.dataset.fcModebarExportTrigger = ""; +grip.innerHTML = this._icon("drag"); +grip.style.cssText = +"display:flex;align-items:center;justify-content:center;pointer-events:auto;touch-action:none;"; +this._applySlot(grip, "modebar_button"); +bar.appendChild(grip); +const DRAG_THRESHOLD_PX = 6; +let modebarDrag = null; +let suppressGripClickUntil = 0; +this._listen(grip, "pointerdown", (e) => { +if (e.pointerType === "mouse" && e.button !== 0) return; +e.stopPropagation(); +const barRect = bar.getBoundingClientRect(); +modebarDrag = { +pointerId: e.pointerId, +startX: e.clientX, +startY: e.clientY, +dx: e.clientX - barRect.left, +dy: e.clientY - barRect.top, +moved: false, +}; +try { grip.setPointerCapture(e.pointerId); } catch (_err) { } +setVisible(true); +}); +this._listen(grip, "pointermove", (e) => { +if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; +const distance = Math.hypot(e.clientX - modebarDrag.startX, e.clientY - modebarDrag.startY); +if (!modebarDrag.moved) { +if (distance < DRAG_THRESHOLD_PX) return; +modebarDrag.moved = true; +this._modebarDragging = true; +this._modebarMoved = true; +bar.style.transition = "none"; +setZoomMenuOpen(false); +setSelectMenuOpen(false); +setExportMenuOpen(false); +} +const rootRect = root.getBoundingClientRect(); +const left = e.clientX - rootRect.left - modebarDrag.dx; +const top = e.clientY - rootRect.top - modebarDrag.dy; +this._clampModebar(left, top); +}); +const endModebarDrag = (e) => { +if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; +const moved = modebarDrag.moved; +const cancelled = e.type === "pointercancel"; +modebarDrag = null; +this._modebarDragging = false; +bar.style.transition = "opacity .15s"; +setVisible(root.matches(":hover")); +if (moved || cancelled) { +suppressGripClickUntil = performance.now() + 100; +} +}; +this._listen(grip, "pointerup", endModebarDrag); +this._listen(grip, "pointercancel", endModebarDrag); +this._listen(grip, "click", (e) => { +e.stopPropagation(); +if (performance.now() <= suppressGripClickUntil) { +suppressGripClickUntil = 0; +return; +} +setExportMenuOpen(!this._exportMenuOpen); +}); const mk = (name, title, onClick, toggles) => { const b = document.createElement("button"); b.type = "button"; b.title = title; +b.setAttribute("aria-label", title); +if (toggles) b.setAttribute("aria-pressed", "false"); b.innerHTML = this._icon(name); b.style.cssText = "display:flex;align-items:center;justify-content:center;pointer-events:auto;"; @@ -5315,23 +6238,403 @@ bar.appendChild(b); if (toggles) this._modeBtns[toggles] = b; return b; }; -mk("zoomin", "Zoom in", () => this._zoomBy(0.5, true)); -mk("zoomout", "Zoom out", () => this._zoomBy(2, true)); +const zoomTrigger = mk("zoommenu", "Zoom controls", () => { +setZoomMenuOpen(!this._zoomMenuOpen); +}); +this._zoomMenuButton = zoomTrigger; +zoomTrigger.dataset.fcModebarMenuTrigger = ""; +zoomTrigger.replaceChildren(); +const zoomPercent = document.createElement("span"); +zoomPercent.dataset.fcModebarZoomPercent = ""; +zoomPercent.textContent = "100%"; +zoomTrigger.appendChild(zoomPercent); +const zoomIndicator = document.createElement("span"); +zoomIndicator.dataset.fcModebarMenuIndicator = ""; +zoomIndicator.innerHTML = this._icon("chevrondown"); +zoomTrigger.appendChild(zoomIndicator); +this._zoomMenuLabel = zoomPercent; +zoomTrigger.setAttribute("aria-haspopup", "menu"); +zoomTrigger.setAttribute("aria-expanded", "false"); +const canSelect = this._pickable +&& this._interactionFlag("brush", true) +&& this._interactionFlag("select", true); +let selectTrigger = null; +let selectIndicator = null; +if (canSelect) { +selectTrigger = mk("select", "Selection controls", () => { +setSelectMenuOpen(!this._selectMenuOpen); +}); +selectTrigger.dataset.fcModebarSelect = ""; +selectTrigger.dataset.fcModebarSelectTrigger = ""; +selectTrigger.setAttribute("aria-haspopup", "menu"); +selectTrigger.setAttribute("aria-expanded", "false"); +selectIndicator = document.createElement("span"); +selectIndicator.dataset.fcModebarMenuIndicator = ""; +selectIndicator.innerHTML = this._icon("chevrondown"); +selectTrigger.appendChild(selectIndicator); +this._selectMenuButton = selectTrigger; +} mk("pan", "Pan", () => this._setDragMode("pan"), "pan"); -mk("zoom", "Box zoom", () => this._setDragMode("zoom"), "zoom"); -mk("reset", "Reset view", () => { +const zoomMenu = document.createElement("div"); +zoomMenu.dataset.fcModebarMenu = ""; +zoomMenu.setAttribute("role", "menu"); +zoomMenu.setAttribute("aria-label", "Zoom controls"); +zoomMenu.style.cssText = +"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; +bar.appendChild(zoomMenu); +const zoomMenuItems = []; +const mkZoomItem = (name, label, onClick, toggles, separator = false) => { +const button = document.createElement("button"); +button.type = "button"; +button.tabIndex = -1; +button.dataset.fcModebarMenuItem = name; +if (separator) button.dataset.fcSeparator = ""; +button.setAttribute("role", "menuitem"); +button.style.cssText = +"display:flex;align-items:center;pointer-events:auto;"; +this._applySlot(button, "modebar_button"); +const icon = document.createElement("span"); +icon.dataset.fcModebarMenuIcon = ""; +icon.innerHTML = this._icon(name); +button.appendChild(icon); +const text = document.createElement("span"); +text.textContent = label; +button.appendChild(text); +this._listen(button, "pointerdown", (e) => e.stopPropagation()); +this._listen(button, "click", (e) => { +e.stopPropagation(); +setZoomMenuOpen(false, true); +onClick(); +}); +zoomMenu.appendChild(button); +zoomMenuItems.push(button); +if (toggles) this._modeBtns[toggles] = button; +return button; +}; +const resetView = () => { this._clearSelection(); this._setView(this.view0, { animate: true }); +}; +mkZoomItem("zoomin", "Zoom In", () => this._zoomBy(0.5, true)); +mkZoomItem("zoomout", "Zoom Out", () => this._zoomBy(2, true)); +mkZoomItem("zoom", "Box Zoom", () => this._setDragMode("zoom"), "zoom"); +mkZoomItem("reset", "Reset View", resetView, null, true); +const selectMenu = document.createElement("div"); +selectMenu.dataset.fcModebarMenu = ""; +selectMenu.dataset.fcModebarSelectMenu = ""; +selectMenu.setAttribute("role", "menu"); +selectMenu.setAttribute("aria-label", "Selection controls"); +selectMenu.style.cssText = +"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; +bar.appendChild(selectMenu); +const selectMenuItems = []; +const mkSelectItem = (name, label, mode) => { +const button = document.createElement("button"); +button.type = "button"; +button.tabIndex = -1; +button.dataset.fcModebarMenuItem = name; +button.dataset.fcModebarSelectItem = mode; +button.setAttribute("role", "menuitem"); +button.style.cssText = "display:flex;align-items:center;pointer-events:auto;"; +this._applySlot(button, "modebar_button"); +const icon = document.createElement("span"); +icon.dataset.fcModebarMenuIcon = ""; +icon.innerHTML = this._icon(name); +button.appendChild(icon); +const text = document.createElement("span"); +text.textContent = label; +button.appendChild(text); +this._listen(button, "pointerdown", (e) => e.stopPropagation()); +this._listen(button, "click", (e) => { +e.stopPropagation(); +setSelectMenuOpen(false, true); +this._setDragMode(mode); +}); +selectMenu.appendChild(button); +selectMenuItems.push(button); +this._modeBtns[mode] = button; +}; +if (canSelect) { +mkSelectItem("select", "Box Select", "select"); +mkSelectItem("lasso", "Lasso Select", "select-lasso"); +mkSelectItem("selectx", "X Range", "select-x"); +mkSelectItem("selecty", "Y Range", "select-y"); +} +const exportMenu = document.createElement("div"); +exportMenu.dataset.fcModebarMenu = ""; +exportMenu.dataset.fcModebarExportMenu = ""; +exportMenu.setAttribute("role", "menu"); +exportMenu.setAttribute("aria-label", "Toolbar options"); +exportMenu.style.cssText = +"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; +bar.appendChild(exportMenu); +const exportMenuItems = []; +const mkExportItem = (name, label, onClick, separator = false) => { +const button = document.createElement("button"); +button.type = "button"; +button.tabIndex = -1; +button.dataset.fcModebarMenuItem = name; +button.dataset.fcModebarExportItem = name; +if (separator) button.dataset.fcSeparator = ""; +button.setAttribute("role", "menuitem"); +button.style.cssText = "display:flex;align-items:center;pointer-events:auto;"; +this._applySlot(button, "modebar_button"); +const icon = document.createElement("span"); +icon.dataset.fcModebarMenuIcon = ""; +icon.innerHTML = this._icon(name); +button.appendChild(icon); +const text = document.createElement("span"); +text.textContent = label; +button.appendChild(text); +this._listen(button, "pointerdown", (e) => e.stopPropagation()); +this._listen(button, "click", (e) => { +e.stopPropagation(); +setExportMenuOpen(false, true); +Promise.resolve(onClick()).catch((error) => console.error(`xy: ${label} failed`, error)); +}); +exportMenu.appendChild(button); +exportMenuItems.push(button); +return button; +}; +mkExportItem("png", "Export PNG", () => this._exportPng()); +mkExportItem("svg", "Export SVG", () => this._exportSvg()); +mkExportItem("csv", "Export CSV", () => this._exportCsv()); +setZoomMenuOpen = (open, restoreFocus = false) => { +const show = Boolean(open); +if (show) { +setSelectMenuOpen(false); +setExportMenuOpen(false); +} +this._zoomMenuOpen = show; +zoomTrigger.setAttribute("aria-expanded", String(show)); +if (!show) { +zoomMenu.style.display = "none"; +zoomIndicator.style.transform = "none"; +if (restoreFocus) zoomTrigger.focus(); +return; +} +zoomMenu.style.display = "flex"; +zoomMenu.style.visibility = "hidden"; +const rootRect = root.getBoundingClientRect(); +const barRect = bar.getBoundingClientRect(); +const rootLeft = barRect.left - rootRect.left; +const rootTop = barRect.top - rootRect.top; +const below = bar.offsetHeight + 6; +const above = -zoomMenu.offsetHeight - 6; +const preferredTop = barRect.bottom + 6 + zoomMenu.offsetHeight <= rootRect.bottom +? below +: above; +zoomIndicator.style.transform = preferredTop === above ? "rotate(180deg)" : "none"; +const maxLeft = root.clientWidth - rootLeft - zoomMenu.offsetWidth; +const maxTop = root.clientHeight - rootTop - zoomMenu.offsetHeight; +zoomMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, zoomTrigger.offsetLeft))}px`; +zoomMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; +zoomMenu.style.visibility = "visible"; +}; +setSelectMenuOpen = (open, restoreFocus = false) => { +if (!selectTrigger) return; +const show = Boolean(open); +if (show) { +setZoomMenuOpen(false); +setExportMenuOpen(false); +} +this._selectMenuOpen = show; +selectTrigger.setAttribute("aria-expanded", String(show)); +if (!show) { +selectMenu.style.display = "none"; +selectIndicator.style.transform = "none"; +if (restoreFocus) selectTrigger.focus(); +return; +} +selectMenu.style.display = "flex"; +selectMenu.style.visibility = "hidden"; +const rootRect = root.getBoundingClientRect(); +const barRect = bar.getBoundingClientRect(); +const rootLeft = barRect.left - rootRect.left; +const rootTop = barRect.top - rootRect.top; +const below = bar.offsetHeight + 6; +const above = -selectMenu.offsetHeight - 6; +const preferredTop = barRect.bottom + 6 + selectMenu.offsetHeight <= rootRect.bottom +? below +: above; +selectIndicator.style.transform = preferredTop === above ? "rotate(180deg)" : "none"; +const maxLeft = root.clientWidth - rootLeft - selectMenu.offsetWidth; +const maxTop = root.clientHeight - rootTop - selectMenu.offsetHeight; +selectMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, selectTrigger.offsetLeft))}px`; +selectMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; +selectMenu.style.visibility = "visible"; +}; +setExportMenuOpen = (open, restoreFocus = false) => { +const show = Boolean(open); +if (show) { +setZoomMenuOpen(false); +setSelectMenuOpen(false); +} +this._exportMenuOpen = show; +grip.setAttribute("aria-expanded", String(show)); +if (!show) { +exportMenu.style.display = "none"; +if (restoreFocus) grip.focus(); +return; +} +exportMenu.style.display = "flex"; +exportMenu.style.visibility = "hidden"; +const rootRect = root.getBoundingClientRect(); +const barRect = bar.getBoundingClientRect(); +const rootLeft = barRect.left - rootRect.left; +const rootTop = barRect.top - rootRect.top; +const below = bar.offsetHeight + 6; +const above = -exportMenu.offsetHeight - 6; +const preferredTop = barRect.bottom + 6 + exportMenu.offsetHeight <= rootRect.bottom +? below +: above; +const maxLeft = root.clientWidth - rootLeft - exportMenu.offsetWidth; +const maxTop = root.clientHeight - rootTop - exportMenu.offsetHeight; +exportMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, grip.offsetLeft))}px`; +exportMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; +exportMenu.style.visibility = "visible"; +}; +this._closeModebarMenu = () => { +setZoomMenuOpen(false); +setSelectMenuOpen(false); +setExportMenuOpen(false); +}; +this._listen(document, "pointerdown", (e) => { +if (this._zoomMenuOpen && !bar.contains(e.target)) setZoomMenuOpen(false); +if (this._selectMenuOpen && !bar.contains(e.target)) setSelectMenuOpen(false); +if (this._exportMenuOpen && !bar.contains(e.target)) setExportMenuOpen(false); +}); +this._listen(zoomTrigger, "keydown", (e) => { +if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; +e.preventDefault(); +e.stopPropagation(); +setZoomMenuOpen(true); +const index = e.key === "ArrowDown" ? 0 : zoomMenuItems.length - 1; +zoomMenuItems[index].focus(); +}); +this._listen(zoomMenu, "keydown", (e) => { +if (e.key === "Escape") { +e.preventDefault(); +e.stopPropagation(); +setZoomMenuOpen(false, true); +return; +} +if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; +e.preventDefault(); +const current = zoomMenuItems.indexOf(document.activeElement); +let next = e.key === "Home" ? 0 : e.key === "End" ? zoomMenuItems.length - 1 : current; +if (e.key === "ArrowDown") next = (current + 1) % zoomMenuItems.length; +if (e.key === "ArrowUp") next = (current - 1 + zoomMenuItems.length) % zoomMenuItems.length; +zoomMenuItems[next].focus(); +}); +if (selectTrigger) { +this._listen(selectTrigger, "keydown", (e) => { +if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; +e.preventDefault(); +e.stopPropagation(); +setSelectMenuOpen(true); +const index = e.key === "ArrowDown" ? 0 : selectMenuItems.length - 1; +selectMenuItems[index].focus(); +}); +this._listen(selectMenu, "keydown", (e) => { +if (e.key === "Escape") { +e.preventDefault(); +e.stopPropagation(); +setSelectMenuOpen(false, true); +return; +} +if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; +e.preventDefault(); +const current = selectMenuItems.indexOf(document.activeElement); +let next = e.key === "Home" ? 0 : e.key === "End" ? selectMenuItems.length - 1 : current; +if (e.key === "ArrowDown") next = (current + 1) % selectMenuItems.length; +if (e.key === "ArrowUp") { +next = (current - 1 + selectMenuItems.length) % selectMenuItems.length; +} +selectMenuItems[next].focus(); +}); +} +this._listen(grip, "keydown", (e) => { +if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; +e.preventDefault(); +e.stopPropagation(); +setExportMenuOpen(true); +const index = e.key === "ArrowDown" ? 0 : exportMenuItems.length - 1; +exportMenuItems[index].focus(); +}); +this._listen(exportMenu, "keydown", (e) => { +if (e.key === "Escape") { +e.preventDefault(); +e.stopPropagation(); +setExportMenuOpen(false, true); +return; +} +if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; +e.preventDefault(); +const current = exportMenuItems.indexOf(document.activeElement); +let next = e.key === "Home" ? 0 : e.key === "End" ? exportMenuItems.length - 1 : current; +if (e.key === "ArrowDown") next = (current + 1) % exportMenuItems.length; +if (e.key === "ArrowUp") { +next = (current - 1 + exportMenuItems.length) % exportMenuItems.length; +} +exportMenuItems[next].focus(); }); root.appendChild(bar); +this._fitModebar(); +setVisible(root.matches(":hover")); this._setDragMode(this.dragMode); }, +_fitModebar() { +const bar = this._modebar; +if (!bar) return; +this._closeModebarMenu?.(); +if (!this._modebarMoved) { +bar.style.top = `${this.plot.y + 4}px`; +bar.style.left = `${this.plot.x + 4}px`; +} +bar.style.display = "flex"; +const fits = +bar.offsetWidth + 8 <= this.plot.w && bar.offsetHeight + 8 <= this.plot.h; +if (!fits) { +bar.style.display = "none"; +return; +} +this._clampModebar(); +}, _setDragMode(mode) { this.dragMode = mode; if (this.canvas) this.canvas.dataset.fcDragmode = mode; for (const [name, btn] of Object.entries(this._modeBtns || {})) { btn.classList.toggle("fc-active", name === mode); +btn.setAttribute("aria-pressed", String(name === mode)); } +this._zoomMenuButton?.classList.toggle("fc-active", mode === "zoom"); +this._selectMenuButton?.classList.toggle("fc-active", mode.startsWith("select")); +}, +_updateZoomMenuLabel() { +if (!this._zoomMenuLabel || !this.view || !this.view0) return; +const axisPercent = (axisId, lo, hi, homeLo, homeHi) => { +const axis = this._axis(axisId); +const span = Math.abs(this._axisCoord(axis, hi) - this._axisCoord(axis, lo)); +const homeSpan = Math.abs( +this._axisCoord(axis, homeHi) - this._axisCoord(axis, homeLo) +); +return Number.isFinite(span) && span > 0 && Number.isFinite(homeSpan) && homeSpan > 0 +? (homeSpan / span) * 100 +: null; +}; +const percent = axisPercent("x", this.view.x0, this.view.x1, this.view0.x0, this.view0.x1) +?? axisPercent("y", this.view.y0, this.view.y1, this.view0.y0, this.view0.y1) +?? 100; +const rounded = Math.round(percent); +const exactText = percent < 1 ? "<1%" : `${rounded}%`; +const displayText = rounded > 999 ? `${String(rounded).slice(0, 3)}…%` : exactText; +if (this._zoomMenuLabel.dataset.fcZoomExact === exactText +&& this._zoomMenuLabel.textContent === displayText) return; +this._zoomMenuLabel.textContent = displayText; +this._zoomMenuLabel.dataset.fcZoomExact = exactText; +this._zoomMenuButton.title = `Zoom controls (${exactText})`; +this._zoomMenuButton.setAttribute("aria-label", `Zoom controls, ${exactText}`); }, _prefersReducedMotion() { return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; @@ -5477,6 +6780,179 @@ const y0 = yReversed ? yhi : ylo; const y1 = yReversed ? ylo : yhi; this._setView({ x0, x1, y0, y1 }, { animate }); }, +_exportFilename(extension) { +const title = String(this.spec.title || "xy-chart") +.trim() +.toLowerCase() +.replace(/[^a-z0-9]+/g, "-") +.replace(/^-+|-+$/g, "") || "xy-chart"; +return `${title}.${extension}`; +}, +_downloadExport(blob, filename) { +const url = URL.createObjectURL(blob); +const link = document.createElement("a"); +link.href = url; +link.download = filename; +link.style.display = "none"; +document.body.appendChild(link); +link.click(); +link.remove(); +setTimeout(() => URL.revokeObjectURL(url), 0); +}, +_exportSvgMarkup() { +this._drawNow?.(); +this.gl?.finish?.(); +const width = this.size.w; +const height = this.size.h; +const clone = this.root.cloneNode(true); +clone.style.width = `${width}px`; +clone.style.height = `${height}px`; +clone.style.margin = "0"; +clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); +const computed = getComputedStyle(this.root); +const inheritedProperties = [ +"color", "font-family", "font-size", "font-style", "font-weight", +"letter-spacing", "line-height", +]; +const chartTokens = [ +"--chart-bg", "--chart-text", "--chart-grid", "--chart-axis", +"--chart-tooltip-bg", "--chart-tooltip-text", "--chart-legend-bg", +"--chart-badge-bg", "--chart-badge-text", "--chart-modebar-bg", +"--chart-modebar-active", "--chart-selection", "--chart-selection-fill", +"--chart-zoom-selection", "--chart-zoom-selection-fill", "--chart-crosshair", +"--chart-annotation-text", "--chart-cursor", "--chart-cursor-pan", +]; +for (let i = 0; i < computed.length; i++) { +const property = computed.item(i); +if (!property.startsWith("--")) continue; +const value = computed.getPropertyValue(property).trim(); +if (value) clone.style.setProperty(property, value); +} +for (const property of [...inheritedProperties, ...chartTokens]) { +const value = computed.getPropertyValue(property).trim(); +if (value) clone.style.setProperty(property, value); +} +const sourceCanvases = [...this.root.querySelectorAll("canvas")]; +const clonedCanvases = [...clone.querySelectorAll("canvas")]; +for (let i = 0; i < clonedCanvases.length; i++) { +const source = sourceCanvases[i]; +const target = clonedCanvases[i]; +if (!source || !target) continue; +const image = document.createElement("img"); +image.setAttribute("src", source.toDataURL("image/png")); +image.setAttribute("alt", ""); +image.setAttribute("style", target.getAttribute("style") || ""); +image.setAttribute("width", String(source.clientWidth || source.width)); +image.setAttribute("height", String(source.clientHeight || source.height)); +for (const attr of target.attributes) { +if (attr.name.startsWith("data-")) image.setAttribute(attr.name, attr.value); +} +target.replaceWith(image); +} +clone.querySelectorAll( +'[data-fc-slot="modebar"],[data-fc-slot="tooltip"],' + +'[data-fc-slot="selection"],[data-fc-selection-lasso-overlay],' + +'[data-fc-slot="crosshair_x"],[data-fc-slot="crosshair_y"]' +).forEach((node) => node.remove()); +const stylesheet = document.createElement("style"); +stylesheet.textContent = FC_CHROME_CSS; +clone.prepend(stylesheet); +const content = new XMLSerializer().serializeToString(clone); +return `` + +`${content}`; +}, +_exportSvg() { +const svg = this._exportSvgMarkup(); +this._downloadExport( +new Blob([svg], { type: "image/svg+xml;charset=utf-8" }), +this._exportFilename("svg") +); +}, +_exportPng() { +const svg = this._exportSvgMarkup(); +const sourceUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +const image = new Image(); +return new Promise((resolve, reject) => { +image.onload = () => { +const scale = Math.max(1, window.devicePixelRatio || 1); +const canvas = document.createElement("canvas"); +canvas.width = Math.round(this.size.w * scale); +canvas.height = Math.round(this.size.h * scale); +const ctx = canvas.getContext("2d"); +ctx.scale(scale, scale); +ctx.drawImage(image, 0, 0, this.size.w, this.size.h); +canvas.toBlob((blob) => { +if (!blob) { +reject(new Error("PNG encoding returned no data")); +return; +} +this._downloadExport(blob, this._exportFilename("png")); +resolve(); +}, "image/png"); +}; +image.onerror = () => { +reject(new Error("chart SVG could not be rasterized")); +}; +image.src = sourceUrl; +}); +}, +_exportCsvText() { +const columns = ["trace", "name", "kind", "index", "x", "y", "x0", "x1", "y0", "y1", "value"]; +const rows = [columns]; +const clean = (value) => Number.isFinite(value) ? value : ""; +for (const g of this.gpuTraces || []) { +const trace = g.trace || {}; +const prefix = [trace.id ?? "", trace.name ?? "", trace.kind ?? ""]; +if (g._cpuRect) { +const r = g._cpuRect; +const n = Math.min(r.x0.length, r.x1.length, r.y0.length, r.y1.length); +for (let i = 0; i < n; i++) { +rows.push([...prefix, i, "", "", +clean(this._decodeValue(r.x0, r.x0Meta, i)), +clean(this._decodeValue(r.x1, r.x1Meta, i)), +clean(this._decodeValue(r.y0, r.y0Meta, i)), +clean(this._decodeValue(r.y1, r.y1Meta, i)), ""]); +} +continue; +} +if (g.heatmap && g._cpuHeatmap) { +const h = g.heatmap; +for (let i = 0; i < g._cpuHeatmap.grid.length; i++) { +const row = Math.floor(i / h.w); +const col = i % h.w; +const x = h.xRange[0] + (col + 0.5) * ((h.xRange[1] - h.xRange[0]) / h.w); +const y = h.yRange[0] + (row + 0.5) * ((h.yRange[1] - h.yRange[0]) / h.h); +const value = this._denormalizeUnit(g._cpuHeatmap.grid[i], trace.color?.domain); +rows.push([...prefix, i, clean(x), clean(y), "", "", "", "", clean(value)]); +} +continue; +} +const cpu = g._cpu; +if (!cpu?.x || !cpu?.y) continue; +const n = Math.min(cpu.x.length, cpu.y.length, g.n || Infinity); +for (let i = 0; i < n; i++) { +rows.push([...prefix, i, +clean(this._decodeValue(cpu.x, cpu.xMeta || g.xMeta, i)), +clean(this._decodeValue(cpu.y, cpu.yMeta || g.yMeta, i)), +"", "", "", "", ""]); +} +} +const quote = (value) => { +const text = String(value ?? ""); +const escaped = text.split('"').join('""'); +return text.includes(",") || text.includes('"') || text.includes("\r") || text.includes("\n") +? `"${escaped}"` +: text; +}; +return rows.map((row) => row.map(quote).join(",")).join("\r\n") + "\r\n"; +}, +_exportCsv() { +this._downloadExport( +new Blob([this._exportCsvText()], { type: "text/csv;charset=utf-8" }), +this._exportFilename("csv") +); +}, _icon(name) { const svg = (body) => `' + case "zoom": return svg(''); +case "select": +return svg('' + +''); +case "lasso": +return svg('' + +''); +case "selectx": +return svg('' + +''); +case "selecty": +return svg('' + +''); +case "chevrondown": +return svg(''); +case "collapse": +return svg(''); +case "expand": +return svg(''); +case "png": +return svg('' + +''); +case "svg": +return svg('' + +''); +case "csv": +return svg('' + +''); case "reset": return svg(''); +case "drag": +return svg('' + +'' + +'' + +'' + +'' + +''); default: return svg(""); } @@ -5765,10 +7279,13 @@ this.draw(); } else if (msg.type === "append") { this._applyAppend(msg, buffers); } else if (msg.type === "pick_result") { +if (msg.seq !== undefined && msg.seq !== this._pickSeq) return; if (!msg.row) { this.tooltip.style.display = "none"; return; } this._lastRow = msg.row; const xy = this._lastHoverXY; -if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY); +if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY, { +announce: !this._a11yKeyboardReadout, +}); if (this._interactionFlag("hover")) { this._dispatchChartEvent("hover", { row: msg.row, @@ -5957,7 +7474,7 @@ segments: SEGMENT_MARK, triangle_mesh: MESH_MARK, error_band: AREA_MARK, hexbin: { -build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), +build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); diff --git a/python/reflex-xy/reflex_xy/state_bridge.py b/python/reflex-xy/reflex_xy/state_bridge.py index a1f1a733..20eac2d7 100644 --- a/python/reflex-xy/reflex_xy/state_bridge.py +++ b/python/reflex-xy/reflex_xy/state_bridge.py @@ -16,6 +16,7 @@ from __future__ import annotations +import inspect from typing import TYPE_CHECKING, Any, Optional from .registry import _figure_of @@ -53,7 +54,12 @@ async def rebuild_figure(app: Any, parsed: ParsedToken) -> Optional["Figure"]: token = rx.BaseStateToken(ident=parsed.client_token, cls=rx.State) root = await app.state_manager.get_state(token) substate = await root.get_state(state_cls) - chart = builder(substate) + # Async builders (AsyncFigureVar) await their data source here exactly + # as they would during normal var evaluation. + if inspect.iscoroutinefunction(builder): + chart = await builder(substate) + else: + chart = builder(substate) if chart is None: return None return _figure_of(chart) diff --git a/python/reflex-xy/reflex_xy/vars.py b/python/reflex-xy/reflex_xy/vars.py index b970ff46..1c582b98 100644 --- a/python/reflex-xy/reflex_xy/vars.py +++ b/python/reflex-xy/reflex_xy/vars.py @@ -14,61 +14,107 @@ the component resubscribes, the registry misses, and the namespace rebuilds from state via the builder this module attached to the var. -The builder must be a pure function of its state instance (same discipline -as any cached computed var) — that purity is exactly what makes the figure -a rebuildable cache instead of precious process state. +Sync and async builders are both supported, mirroring reflex's own +`ComputedVar`/`AsyncComputedVar` split (and using the same +`iscoroutinefunction` dispatch `rx.var` uses): an ``async def`` builder may +await a database, an HTTP endpoint, or a dataframe store, and evaluates +under reflex's normal async-var machinery — cached the same way, marked +dirty the same way. + +Builders must be pure functions of their state instance (same discipline as +any cached computed var) — that purity is exactly what makes the figure a +rebuildable cache instead of precious process state. For async builders the +bar is "deterministic given state": fetching the rows your state points at +is fine; the rebuild path (state_bridge.py) will await the same fetch when +a fresh worker needs the figure back. """ from __future__ import annotations +import inspect from collections.abc import Callable from typing import Any, Optional, overload -from reflex_base.vars.base import ComputedVar +from reflex_base.vars.base import AsyncComputedVar, ComputedVar from .registry import _figure_of, registry from .tokens import BUILDER_ATTR, build_state_token -__all__ = ["FigureVar", "figure"] +__all__ = ["AsyncFigureVar", "FigureVar", "figure"] + + +def _builder_target(var: Any, obj: Any) -> Any: + """Point dependency tracking at the *builder*, not the token wrapper: + reflex should track what the chart reads, and the wrapper fget reads + nothing but the router.""" + if obj is not None: + return obj + return getattr(var._fget, BUILDER_ATTR, None) class FigureVar(ComputedVar): - """ComputedVar whose value is a figure token and whose dependencies are - the *builder's* — reflex tracks what the chart reads, not what the - token-minting wrapper reads.""" + """ComputedVar whose value is a figure token (sync builder).""" def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: - if obj is None: - builder = getattr(self._fget, BUILDER_ATTR, None) - if builder is not None: - obj = builder - return super()._deps(objclass, obj=obj) + return ComputedVar._deps(self, objclass, obj=_builder_target(self, obj)) + + +class AsyncFigureVar(AsyncComputedVar): + """AsyncComputedVar whose value is a figure token (async builder).""" + + def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: + return AsyncComputedVar._deps(self, objclass, obj=_builder_target(self, obj)) + + +def _mint_token(state: Any, builder_name: str) -> Optional[str]: + """Deterministic token for this (session, state, var) — or None + pre-hydration (no session yet, so no figure to serve; the component + treats "" as "not ready" and waits for the hydrated value).""" + client_token = state.router.session.client_token + if not client_token: + return None + return build_state_token(client_token, type(state).get_full_name(), builder_name) + + +def _publish(token: str, chart: Any) -> str: + if chart is None: + registry.release(token) + return "" + registry.publish(token, _figure_of(chart)) + return token + + +def _adopt_identity(fget: Any, builder: Callable[..., Any], name: str) -> None: + fget.__name__ = name + fget.__qualname__ = getattr(builder, "__qualname__", name) + fget.__module__ = getattr(builder, "__module__", fget.__module__) + fget.__doc__ = builder.__doc__ + setattr(fget, BUILDER_ATTR, builder) def _make_fget(builder: Callable[[Any], Any]) -> Callable[[Any], str]: builder_name = _fn_name(builder) def fget(self: Any) -> str: - client_token = self.router.session.client_token - if not client_token: - # Pre-hydration evaluation (e.g. initial state snapshot at - # compile time): no session yet, so no figure to serve. The - # component treats "" as "not ready" and waits for the - # hydrated value. + token = _mint_token(self, builder_name) + if token is None: return "" - token = build_state_token(client_token, type(self).get_full_name(), builder_name) - chart = builder(self) - if chart is None: - registry.release(token) + return _publish(token, builder(self)) + + _adopt_identity(fget, builder, builder_name) + return fget + + +def _make_async_fget(builder: Callable[[Any], Any]) -> Callable[[Any], Any]: + builder_name = _fn_name(builder) + + async def fget(self: Any) -> str: + token = _mint_token(self, builder_name) + if token is None: return "" - registry.publish(token, _figure_of(chart)) - return token + return _publish(token, await builder(self)) - fget.__name__ = builder_name - fget.__qualname__ = getattr(builder, "__qualname__", builder_name) - fget.__module__ = getattr(builder, "__module__", fget.__module__) - fget.__doc__ = builder.__doc__ - setattr(fget, BUILDER_ATTR, builder) + _adopt_identity(fget, builder, builder_name) return fget @@ -81,18 +127,18 @@ def _fn_name(fn: Callable[..., Any]) -> str: @overload -def figure(builder: Callable[[Any], Any]) -> FigureVar: ... +def figure(builder: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar": ... @overload def figure( builder: None = None, **var_kwargs: Any -) -> Callable[[Callable[[Any], Any]], FigureVar]: ... +) -> Callable[[Callable[[Any], Any]], "FigureVar | AsyncFigureVar"]: ... def figure( builder: Optional[Callable[[Any], Any]] = None, **var_kwargs: Any -) -> "FigureVar | Callable[[Callable[[Any], Any]], FigureVar]": +) -> "FigureVar | AsyncFigureVar | Callable[[Callable[[Any], Any]], FigureVar | AsyncFigureVar]": """Declare a chart on a Reflex state class. Usage:: @@ -105,16 +151,22 @@ def chart(self) -> xy.Chart: x, y = self._points(self.n) return xy.scatter_chart(xy.scatter(x, y)) + @reflex_xy.figure + async def remote(self) -> xy.Chart: + rows = await fetch_rows(self.query) # db / http / store + return xy.line_chart(xy.line(rows.t, rows.value)) + # in the page: reflex_xy.chart(Dash.chart, height="480px") The method must return a public ``xy`` chart (or an internal - Figure), or ``None`` for "no chart right now". Keyword arguments pass - through to reflex's ``ComputedVar`` (``deps=``, ``auto_deps=``, - ``interval=``, ...); dependencies are auto-tracked from the builder's - body by default, exactly like a normal ``@rx.var``. + Figure), or ``None`` for "no chart right now". ``async def`` builders + become reflex ``AsyncComputedVar``s (same dispatch rule as ``rx.var``). + Keyword arguments pass through to reflex's computed var (``deps=``, + ``auto_deps=``, ``interval=``, ...); dependencies are auto-tracked from + the builder's body by default, exactly like a normal ``@rx.var``. """ - def _decorate(fn: Callable[[Any], Any]) -> FigureVar: + def _decorate(fn: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar": if _fn_name(fn).startswith("_"): # Backend (underscore) vars never reach the client, but the # token must — refuse early with a clear message instead of @@ -124,6 +176,8 @@ def _decorate(fn: Callable[[Any], Any]) -> FigureVar: ) raise ValueError(msg) var_kwargs.setdefault("cache", True) + if inspect.iscoroutinefunction(fn): + return AsyncFigureVar(fget=_make_async_fget(fn), return_type=str, **var_kwargs) return FigureVar(fget=_make_fget(fn), return_type=str, **var_kwargs) if builder is None: diff --git a/tests/reflex_adapter/test_async_figure_var.py b/tests/reflex_adapter/test_async_figure_var.py new file mode 100644 index 00000000..3dce9e3a --- /dev/null +++ b/tests/reflex_adapter/test_async_figure_var.py @@ -0,0 +1,144 @@ +"""Async figure builders: @reflex_xy.figure on `async def`, mirroring +reflex's ComputedVar/AsyncComputedVar split.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import numpy as np +import pytest +import reflex as rx +import reflex_xy +from reflex.istate.manager.memory import StateManagerMemory +from reflex_base.vars.base import AsyncComputedVar +from reflex_xy.state_bridge import make_rebuild_hook +from reflex_xy.tokens import build_state_token +from reflex_xy.vars import AsyncFigureVar, FigureVar + +import xy + +from .conftest import make_router_data + +BUILDER_CALLS = {"count": 0} + + +async def _fetch_scale() -> float: + """Stands in for a database / HTTP / dataframe-store round trip.""" + await asyncio.sleep(0) + return 3.0 + + +class AsyncVarDemo(rx.State): + n: int = 50 + _offset: float = 0.0 + + @reflex_xy.figure + async def chart(self) -> xy.Chart: + BUILDER_CALLS["count"] += 1 + scale = await _fetch_scale() + xs = np.linspace(0.0, 1.0, self.n) + return xy.scatter_chart(xy.scatter(xs, xs * scale + self._offset), width=400, height=300) + + @reflex_xy.figure + async def maybe_chart(self): + if self.n < 0: + return None + xs = np.linspace(0.0, 1.0, 4) + return xy.line_chart(xy.line(xs, xs), width=300, height=200) + + @reflex_xy.figure + def sync_chart(self) -> xy.Chart: + xs = np.linspace(0.0, 1.0, 8) + return xy.line_chart(xy.line(xs, xs), width=300, height=200) + + +def hydrated_substate(client_token: str) -> AsyncVarDemo: + root = rx.State(_reflex_internal_init=True) + root.router = make_router_data(client_token) + return root.get_substate(tuple(AsyncVarDemo.get_full_name().split("."))[1:]) + + +def test_dispatch_mirrors_reflex(): + """Same rule rx.var applies: iscoroutinefunction -> the Async variant.""" + assert isinstance(AsyncVarDemo.computed_vars["chart"], AsyncFigureVar) + assert isinstance(AsyncVarDemo.computed_vars["chart"], AsyncComputedVar) + assert isinstance(AsyncVarDemo.computed_vars["sync_chart"], FigureVar) + assert not isinstance(AsyncVarDemo.computed_vars["sync_chart"], AsyncComputedVar) + + +def test_deps_track_the_async_builder_body(): + deps = AsyncVarDemo.computed_vars["chart"]._deps(AsyncVarDemo) + assert deps == {AsyncVarDemo.get_full_name(): {"n", "_offset"}} + + +def test_await_registers_caches_and_rebuilds(_fresh_registry, client_token): + state = hydrated_substate(client_token) + calls_before = BUILDER_CALLS["count"] + + async def main(): + token = await state.chart + entry = _fresh_registry.get(token) + assert entry is not None + assert entry.figure.traces[0].n_points == 50 + + # cache hit: the builder (and its awaited fetch) must not rerun + assert await state.chart == token + assert BUILDER_CALLS["count"] == calls_before + 1 + + # dependency change -> dirty -> re-await rebuilds, token stable + state.n = 120 + type(state).computed_vars["chart"].mark_dirty(state) + assert await state.chart == token + assert BUILDER_CALLS["count"] == calls_before + 2 + assert _fresh_registry.get(token).version == 2 + assert _fresh_registry.get(token).figure.traces[0].n_points == 120 + + asyncio.run(main()) + + +def test_pre_hydration_returns_empty(_fresh_registry): + root = rx.State(_reflex_internal_init=True) + state = root.get_substate(tuple(AsyncVarDemo.get_full_name().split("."))[1:]) + assert asyncio.run(state.chart) == "" + assert len(_fresh_registry) == 0 + + +def test_none_chart_unregisters(_fresh_registry, client_token): + state = hydrated_substate(client_token) + + async def main(): + token = await state.maybe_chart + assert _fresh_registry.get(token) is not None + state.n = -1 + type(state).computed_vars["maybe_chart"].mark_dirty(state) + assert await state.maybe_chart == "" + assert _fresh_registry.get(token) is None + + asyncio.run(main()) + + +def test_rebuild_from_state_awaits_async_builder(_fresh_registry, client_token): + """The reconnect-on-a-fresh-node path awaits the data source again.""" + app = SimpleNamespace(state_manager=StateManagerMemory()) + token_obj = rx.BaseStateToken(ident=client_token, cls=rx.State) + + async def main(): + async with app.state_manager.modify_state(token_obj) as root: + sub = await root.get_state(AsyncVarDemo) + sub.n = 77 + hook = make_rebuild_hook(app) + return await hook(build_state_token(client_token, AsyncVarDemo.get_full_name(), "chart")) + + figure = asyncio.run(main()) + assert figure is not None + assert figure.traces[0].n_points == 77 + + +def test_underscore_async_builder_rejected(): + with pytest.raises(ValueError, match="must not start with '_'"): + + class BadAsync(rx.State): # noqa: F841 - definition is the assertion + @reflex_xy.figure + async def _hidden(self): + return None From 78c5611bf078bc055790d598652b7401f49da0ec Mon Sep 17 00:00:00 2001 From: Masen Date: Thu, 16 Jul 2026 01:08:25 +0000 Subject: [PATCH 5/5] Fix xy sdist scope; link the render client from the installed xy package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two packaging-hygiene fixes for PR #55: CI ('Source distribution' job): the root sdist include swept all of python/, dragging the separately-distributed reflex-xy adapter — and its reflex.lock frontend pins, which scripts/verify_sdist.py rightly forbids — into the xy tarball. Narrow the include to python/xy and exclude the adapter tree + its tests explicitly (hatchling auto-admits readme-named files, so the exclude is load-bearing). Verified locally with the exact CI sequence: uv build --sdist + verify_sdist now pass with zero adapter entries. Drift elimination: reflex_xy no longer carries a copy of xy_client.js at all. register() links the render client out of the installed xy package (xy/static/index.js) into the app's assets tree, repairing stale links when the install moves — the JS that renders a payload is now always the build that shipped with the Python that produced it, replacing drift *detection* (build-emitted copy + parity test) with structural impossibility. Drops the js/build.mjs third output and the wheel force-include; the adapter wheel ships only XYChart.jsx. 111 tests (adapter + sdist guards) and the cold-start browser E2E pass. --- CLAUDE.md | 4 +- docs/design/reflex-integration.md | 11 +- js/build.mjs | 15 +- pyproject.toml | 12 +- python/reflex-xy/pyproject.toml | 10 +- python/reflex-xy/reflex_xy/assets/__init__.py | 70 +- .../reflex-xy/reflex_xy/assets/xy_client.js | 7583 ----------------- tests/reflex_adapter/test_assets.py | 45 +- 8 files changed, 127 insertions(+), 7623 deletions(-) delete mode 100644 python/reflex-xy/reflex_xy/assets/xy_client.js diff --git a/CLAUDE.md b/CLAUDE.md index cc075b00..3422da37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,9 @@ code comments cite dossier sections (e.g. §16 = deep-zoom re-centering). 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. Its `assets/xy_client.js` is a build artifact of `js/build.mjs`. + 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 diff --git a/docs/design/reflex-integration.md b/docs/design/reflex-integration.md index 1cc18b03..57721ffb 100644 --- a/docs/design/reflex-integration.md +++ b/docs/design/reflex-integration.md @@ -305,9 +305,12 @@ but dispatches no backend events. `chart()` is a plain `rx.Component` whose `library` is a **local JSX shared asset** (`$/public/external/reflex_xy/assets/XYChart.jsx`, the same mechanism reflex's own radix color-mode provider uses) — no npm package, no -CDN. Beside it ships `xy_client.js`, a byte-exact copy of the wheel's -ESM render client (`node js/build.mjs` emits both; a parity test fails on -drift): one renderer for notebooks, static export, and Reflex. +CDN. Beside it, `register()` links `xy_client.js` **out of the installed +`xy` package** (`xy/static/index.js`): the adapter carries no copy +of the render client at all, so client/kernel drift is structurally +impossible — the JS that renders a payload is always the build that shipped +with the Python that produced it. One renderer for notebooks, static +export, and Reflex. The wrapper: opens/reuses the shared namespace socket, `sub`s with the element's measured width, builds a `ChartView` per `payload` (full refresh = @@ -352,7 +355,7 @@ python/reflex-xy/ dispatches token (live) vs Chart (static tier) reflex_xy/payload_asset.py static tier: Chart -> content-addressed XYBF asset in assets/xy/ (§3.4) - reflex_xy/assets/ XYChart.jsx + xy_client.js (build artifact) + reflex_xy/assets/ XYChart.jsx; links xy's installed render client examples/demo_app/ 1M-point drilldown + hover + cross-filter + stream + a direct-Chart static payload tests/reflex_adapter/ 65 tests: token/registry/var/bridge/payload-asset diff --git a/js/build.mjs b/js/build.mjs index bc984b86..a7f2928b 100644 --- a/js/build.mjs +++ b/js/build.mjs @@ -225,17 +225,14 @@ 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 ships the identical ESM client as a bundler-visible -// shared asset (docs/design/reflex-integration.md §5): one renderer for -// notebooks, static export, and Reflex. Emitted here so the copies can -// never drift — the --check mode and tests/reflex_xy/test_assets.py both -// fail on a stale copy. -const reflexAssetsDir = join(here, "..", "python", "reflex-xy", "reflex_xy", "assets"); +// 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 = [ [outDir, "index.js", esm], [outDir, "standalone.js", iife], - [reflexAssetsDir, "xy_client.js", esm], ]; if (checkOnly) { @@ -253,7 +250,7 @@ if (checkOnly) { } if (stale.length) { console.error( - `static JS bundle check failed: ${stale.join(", ")}. Run \`node js/build.mjs\` and commit python/xy/static/*.js + python/reflex-xy/reflex_xy/assets/xy_client.js.` + `static JS bundle check failed: ${stale.join(", ")}. Run \`node js/build.mjs\` and commit python/xy/static/*.js.` ); process.exit(1); } @@ -263,5 +260,5 @@ if (checkOnly) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, name), data); } - console.log(`built static/index.js, static/standalone.js, and reflex_xy assets from ${PARTS.length} parts`); + console.log(`built static/index.js and static/standalone.js from ${PARTS.length} parts`); } diff --git a/pyproject.toml b/pyproject.toml index 33e62c34..d90423ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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] diff --git a/python/reflex-xy/pyproject.toml b/python/reflex-xy/pyproject.toml index 08cce911..6f2d71a8 100644 --- a/python/reflex-xy/pyproject.toml +++ b/python/reflex-xy/pyproject.toml @@ -31,9 +31,7 @@ Repository = "https://github.com/reflex-dev/xy" [tool.hatch.build.targets.wheel] packages = ["reflex_xy"] - -[tool.hatch.build.targets.wheel.force-include] -# The render client is a build artifact synced from the xy repo -# (node js/build.mjs); ship whatever is committed here. -"reflex_xy/assets/xy_client.js" = "reflex_xy/assets/xy_client.js" -"reflex_xy/assets/XYChart.jsx" = "reflex_xy/assets/XYChart.jsx" +# reflex_xy/assets/XYChart.jsx ships as ordinary package data; the render +# client is NOT packaged — it links out of the installed xy wheel at +# app compile time (see reflex_xy/assets/__init__.py), so it can never +# drift from the kernel that produces its payloads. diff --git a/python/reflex-xy/reflex_xy/assets/__init__.py b/python/reflex-xy/reflex_xy/assets/__init__.py index 43ba6cdb..7af7525a 100644 --- a/python/reflex-xy/reflex_xy/assets/__init__.py +++ b/python/reflex-xy/reflex_xy/assets/__init__.py @@ -1,29 +1,79 @@ """Frontend assets for the Reflex component. -Two files ship here: +One file ships in this package: - ``XYChart.jsx`` — the React wrapper (multiplexes the `/_xy` namespace onto the app's existing websocket and drives ChartView). -- ``xy_client.js`` — a byte-exact copy of the render client - (``python/xy/static/index.js``); ``node js/build.mjs`` regenerates both - and ``tests/reflex_xy/test_assets.py`` fails on drift. + +The render client itself (``xy_client.js``) is deliberately NOT packaged +here: `register()` links it out of the **installed ``xy`` distribution** +(``xy/static/index.js``, the same ESM bundle notebooks load), landing it +beside the wrapper so the wrapper's relative ``./xy_client.js`` import +resolves. Sourcing from the install makes client/kernel drift structurally +impossible — the JS that renders a payload is always the build that shipped +with the Python that produced it. `register()` is deliberately lazy (called from the component factory, not at import): ``rx.asset(shared=True)`` symlinks into ``Path.cwd()/assets``, which -only makes sense while compiling an actual Reflex app. It must be called -from *this* module so the files land in one directory and the wrapper's -relative ``./xy_client.js`` import resolves. +only makes sense while compiling an actual Reflex app. """ from __future__ import annotations +from pathlib import Path + WRAPPER_TAG = "XYChart" +#: Destination directory under the app's assets/ tree — must match where +#: rx.asset(shared=True) puts this module's files, because the wrapper +#: imports the client by relative path. +_EXTERNAL_SUBDIR = Path("external") / "reflex_xy" / "assets" +_CLIENT_NAME = "xy_client.js" + + +def _client_source() -> Path: + """The canonical render client inside the installed xy package.""" + import xy + + source = Path(xy.__file__).resolve().parent / "static" / "index.js" + if not source.exists(): + msg = ( + f"{source} missing — the xy install has no bundled JS client. " + "Dev checkout: run `node js/build.mjs`; otherwise reinstall xy." + ) + raise FileNotFoundError(msg) + return source + + +def _link_client(asset_root: Path) -> None: + """Symlink the installed client beside the wrapper (repairing stale links). + + Unlike rx.asset's shared files (which live at a fixed path next to their + module), the client's location moves whenever the ``xy`` install + does — so an existing link pointing at the wrong target is replaced, not + trusted. + """ + source = _client_source() + dst_dir = asset_root / _EXTERNAL_SUBDIR + dst_dir.mkdir(parents=True, exist_ok=True) + dst = dst_dir / _CLIENT_NAME + if dst.is_symlink() or dst.exists(): + try: + if dst.resolve() == source: + return + except OSError: + pass + dst.unlink() + dst.symlink_to(source) + def register() -> str: - """Symlink both assets into the compiling app; return the wrapper's + """Wire both frontend files into the compiling app; return the wrapper's importable module path (``$/public/external/reflex_xy/assets/...``).""" import reflex as rx + from reflex.assets import EnvironmentVariables - rx.asset("xy_client.js", shared=True) - return rx.asset("XYChart.jsx", shared=True).importable_path + wrapper = rx.asset("XYChart.jsx", shared=True) + if not EnvironmentVariables.REFLEX_BACKEND_ONLY.get(): + _link_client(Path.cwd() / "assets") + return wrapper.importable_path diff --git a/python/reflex-xy/reflex_xy/assets/xy_client.js b/python/reflex-xy/reflex_xy/assets/xy_client.js deleted file mode 100644 index 6e6d7c79..00000000 --- a/python/reflex-xy/reflex_xy/assets/xy_client.js +++ /dev/null @@ -1,7583 +0,0 @@ - -"use strict"; -const PROTOCOL = 3; -const XY_FRAME_MAGIC = [0x58, 0x59, 0x42, 0x46]; -const XY_FRAME_VERSION = 1; -const XY_FRAME_HEADER_SIZE = 24; -const XY_FRAME_ALIGNMENT = 8; -const XY_FRAME_DEFAULT_LIMITS = Object.freeze({ -maxFrameBytes: 512 * 1024 * 1024, -maxMetadataBytes: 8 * 1024 * 1024, -maxBuffers: 4096, -maxBufferBytes: 256 * 1024 * 1024, -}); -function fcByteSpan(value, label = "buffer") { -if (value instanceof ArrayBuffer) return new Uint8Array(value); -if (ArrayBuffer.isView(value)) { -return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); -} -throw new TypeError(`${label} must be an ArrayBuffer or ArrayBuffer view`); -} -function fcFrameLimit(limits, name) { -const fallback = XY_FRAME_DEFAULT_LIMITS[name]; -const value = limits && limits[name] != null ? limits[name] : fallback; -if (!Number.isSafeInteger(value) || value <= 0) { -throw new RangeError(`${name} must be a positive safe integer`); -} -return value; -} -function fcAlign8(value) { -return Math.ceil(value / XY_FRAME_ALIGNMENT) * XY_FRAME_ALIGNMENT; -} -function fcFrameU64(view, offset, label) { -const value = view.getBigUint64(offset, true); -if (value > BigInt(Number.MAX_SAFE_INTEGER)) { -throw new RangeError(`${label} exceeds JavaScript's safe integer range`); -} -return Number(value); -} -function fcRequireZeroPadding(bytes, start, end, label) { -if (end > bytes.byteLength) throw new RangeError(`truncated ${label} padding`); -for (let i = start; i < end; i++) { -if (bytes[i] !== 0) throw new RangeError(`non-zero ${label} padding`); -} -} - -function decodeFrame(body, limits = null) { -const bytes = fcByteSpan(body, "frame body"); -const maxFrameBytes = fcFrameLimit(limits, "maxFrameBytes"); -const maxMetadataBytes = fcFrameLimit(limits, "maxMetadataBytes"); -const maxBuffers = fcFrameLimit(limits, "maxBuffers"); -const maxBufferBytes = fcFrameLimit(limits, "maxBufferBytes"); -if (maxMetadataBytes > maxFrameBytes) { -throw new RangeError("maxMetadataBytes cannot exceed maxFrameBytes"); -} -if (maxBufferBytes > maxFrameBytes) { -throw new RangeError("maxBufferBytes cannot exceed maxFrameBytes"); -} -if (bytes.byteOffset % XY_FRAME_ALIGNMENT !== 0) { -throw new RangeError("frame body must start on an 8-byte boundary"); -} -if (bytes.byteLength > maxFrameBytes) { -throw new RangeError(`frame length ${bytes.byteLength} exceeds limit ${maxFrameBytes}`); -} -if (bytes.byteLength < XY_FRAME_HEADER_SIZE) throw new RangeError("truncated frame header"); -const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); -for (let i = 0; i < XY_FRAME_MAGIC.length; i++) { -if (view.getUint8(i) !== XY_FRAME_MAGIC[i]) throw new RangeError("invalid frame magic"); -} -const version = view.getUint8(4); -if (version !== XY_FRAME_VERSION) throw new RangeError(`unsupported frame version ${version}`); -const flags = view.getUint8(5); -if (flags !== 0) throw new RangeError(`unsupported frame flags 0x${flags.toString(16)}`); -const headerSize = view.getUint16(6, true); -if (headerSize !== XY_FRAME_HEADER_SIZE) { -throw new RangeError(`unsupported frame header size ${headerSize}`); -} -const metadataLength = view.getUint32(8, true); -const bufferCount = view.getUint32(12, true); -const totalLength = fcFrameU64(view, 16, "declared frame length"); -if (totalLength !== bytes.byteLength) { -throw new RangeError( -`declared frame length ${totalLength} does not match body length ${bytes.byteLength}` -); -} -if (metadataLength > maxMetadataBytes) { -throw new RangeError(`metadata length ${metadataLength} exceeds limit ${maxMetadataBytes}`); -} -if (bufferCount > maxBuffers) { -throw new RangeError(`buffer count ${bufferCount} exceeds limit ${maxBuffers}`); -} -const metadataEnd = XY_FRAME_HEADER_SIZE + metadataLength; -if (metadataEnd > bytes.byteLength) throw new RangeError("truncated frame metadata"); -let message; -try { -const metadataBytes = new Uint8Array( -bytes.buffer, -bytes.byteOffset + XY_FRAME_HEADER_SIZE, -metadataLength -); -message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(metadataBytes)); -} catch (error) { -throw new RangeError(`invalid frame metadata JSON: ${error}`); -} -if (!message || Array.isArray(message) || typeof message !== "object") { -throw new RangeError("frame metadata must decode to an object"); -} -let position = fcAlign8(metadataEnd); -fcRequireZeroPadding(bytes, metadataEnd, position, "metadata"); -const buffers = []; -for (let i = 0; i < bufferCount; i++) { -if (position + 8 > bytes.byteLength) throw new RangeError(`truncated buffer ${i} length`); -const bufferLength = fcFrameU64(view, position, `buffer ${i} length`); -position += 8; -if (bufferLength > maxBufferBytes) { -throw new RangeError(`buffer ${i} length ${bufferLength} exceeds limit ${maxBufferBytes}`); -} -const end = position + bufferLength; -if (end > bytes.byteLength) throw new RangeError(`truncated buffer ${i}`); -const absoluteOffset = bytes.byteOffset + position; -if (absoluteOffset % XY_FRAME_ALIGNMENT !== 0) { -throw new RangeError(`buffer ${i} is not 8-byte aligned`); -} -buffers.push(new Uint8Array(bytes.buffer, absoluteOffset, bufferLength)); -const paddedEnd = fcAlign8(end); -fcRequireZeroPadding(bytes, end, paddedEnd, `buffer ${i}`); -position = paddedEnd; -} -if (position !== bytes.byteLength) { -throw new RangeError(`frame has ${bytes.byteLength - position} trailing bytes`); -} -return { message, buffers, version: XY_FRAME_VERSION, byteLength: bytes.byteLength }; -} -const COLORMAP_STOPS = { -binary: [[255, 255, 255], [0, 0, 0]], -gray: [[0, 0, 0], [25, 25, 25], [51, 51, 51], [76, 76, 76], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [230, 230, 230], [255, 255, 255]], -viridis: [[68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], [122, 209, 81], [189, 223, 38], [253, 231, 37]], -plasma: [[13, 8, 135], [65, 4, 157], [106, 0, 168], [143, 13, 164], [177, 42, 144], [204, 71, 120], [225, 100, 98], [242, 132, 75], [252, 166, 54], [252, 206, 37], [240, 249, 33]], -inferno: [[0, 0, 4], [22, 11, 57], [66, 10, 104], [106, 23, 110], [147, 38, 103], [188, 55, 84], [221, 81, 58], [243, 120, 25], [252, 165, 10], [246, 215, 70], [252, 255, 164]], -magma: [[0, 0, 4], [20, 14, 54], [59, 15, 112], [100, 26, 128], [140, 41, 129], [183, 55, 121], [222, 73, 104], [247, 112, 92], [254, 159, 109], [254, 207, 146], [252, 253, 191]], -cividis: [[0, 34, 78], [8, 51, 112], [53, 69, 108], [79, 87, 108], [102, 105, 112], [125, 124, 120], [148, 142, 119], [174, 163, 113], [200, 184, 102], [229, 207, 82], [254, 232, 56]], -coolwarm: [[59, 76, 192], [89, 119, 227], [123, 159, 249], [158, 190, 255], [192, 212, 245], [221, 220, 220], [242, 203, 183], [247, 172, 142], [238, 132, 104], [214, 82, 68], [180, 4, 38]], -turbo: [[48, 18, 59], [69, 89, 203], [62, 155, 254], [25, 213, 205], [70, 248, 132], [164, 252, 60], [225, 221, 55], [254, 164, 49], [240, 91, 18], [195, 37, 3], [122, 4, 3]], -rainbow: [[128, 0, 255], [78, 77, 252], [25, 150, 243], [24, 205, 228], [77, 243, 206], [128, 255, 180], [178, 243, 150], [230, 205, 115], [255, 150, 79], [255, 77, 39], [255, 0, 0]], -jet: [[0, 0, 128], [0, 0, 241], [0, 76, 255], [0, 176, 255], [41, 255, 206], [125, 255, 122], [206, 255, 41], [255, 196, 0], [255, 104, 0], [241, 8, 0], [128, 0, 0]], -rdgy: [[103, 0, 31], [177, 24, 43], [214, 96, 77], [243, 164, 129], [253, 219, 199], [254, 254, 254], [224, 224, 224], [185, 185, 185], [135, 135, 135], [76, 76, 76], [26, 26, 26]], -rdbu: [[103, 0, 31], [177, 24, 43], [214, 96, 77], [243, 164, 129], [253, 219, 199], [246, 247, 247], [209, 229, 240], [144, 196, 221], [67, 147, 195], [32, 101, 171], [5, 48, 97]], -blues: [[247, 251, 255], [227, 238, 249], [208, 225, 242], [183, 212, 234], [148, 196, 223], [106, 174, 214], [74, 152, 201], [46, 126, 188], [23, 100, 171], [8, 74, 145], [8, 48, 107]], -purples: [[252, 251, 253], [242, 240, 247], [226, 226, 239], [206, 207, 229], [182, 182, 216], [158, 154, 200], [134, 131, 189], [114, 98, 172], [97, 64, 155], [79, 31, 139], [63, 0, 125]], -pubu: [[255, 247, 251], [240, 234, 244], [219, 218, 235], [192, 201, 226], [156, 185, 217], [115, 169, 207], [66, 149, 195], [24, 124, 182], [5, 103, 162], [4, 83, 130], [2, 56, 88]], -piyg: [[142, 1, 82], [196, 26, 124], [222, 119, 174], [241, 181, 217], [253, 224, 239], [247, 247, 246], [230, 245, 208], [183, 224, 133], [127, 188, 65], [76, 145, 33], [39, 100, 25]], -prgn: [[64, 0, 75], [117, 41, 130], [153, 112, 171], [193, 164, 206], [231, 212, 232], [246, 247, 246], [217, 240, 211], [165, 218, 159], [90, 174, 97], [26, 119, 54], [0, 68, 27]], -rdylgn: [[165, 0, 38], [214, 47, 39], [244, 109, 67], [253, 173, 96], [254, 224, 139], [254, 255, 190], [217, 239, 139], [165, 216, 106], [102, 189, 99], [25, 151, 80], [0, 104, 55]], -spectral: [[158, 1, 66], [212, 61, 79], [244, 109, 67], [253, 173, 96], [254, 224, 139], [255, 255, 190], [230, 245, 152], [170, 220, 164], [102, 194, 165], [51, 135, 188], [94, 79, 162]], -}; -function colormapStops(name) { -const reversed = typeof name === "string" && name.endsWith("_r"); -const base = reversed ? name.slice(0, -2) : name; -const stops = COLORMAP_STOPS[base] || COLORMAP_STOPS.viridis; -return reversed ? [...stops].reverse() : stops; -} -function buildLutData(name) { -const stops = colormapStops(name); -const N = 256; -const data = new Uint8Array(N * 4); -for (let i = 0; i < N; i++) { -const t = (i / (N - 1)) * (stops.length - 1); -const lo = Math.floor(t); -const hi = Math.min(lo + 1, stops.length - 1); -const f = t - lo; -for (let c = 0; c < 3; c++) { -data[i * 4 + c] = Math.round(stops[lo][c] * (1 - f) + stops[hi][c] * f); -} -data[i * 4 + 3] = 255; -} -return data; -} -function resolveCssColor(host, expr) { -const probe = document.createElement("span"); -probe.style.display = "none"; -probe.style.color = expr; -host.appendChild(probe); -const rgb = getComputedStyle(probe).color; -host.removeChild(probe); -const m = rgb.match(/rgba?\(([^)]+)\)/); -if (!m) return null; -const parts = m[1].split(/[,/\s]+/).filter(Boolean).map(Number); -const [r, g, b, a = 1] = parts; -return [r / 255, g / 255, b / 255, a]; -} -function cssToken(el, name) { -const v = getComputedStyle(el).getPropertyValue(name).trim(); -return v || null; -} -function hexColor(hex) { -const h = hex.replace("#", ""); -if (!/^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(h)) { -return null; -} -const full = h.length === 3 || h.length === 4 ? [...h].map((c) => c + c).join("") : h; -const n = parseInt(full.slice(0, 6), 16); -const a = full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1; -return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a]; -} -function parseColor(host, c, fallback) { -if (!c) return fallback; -if (typeof c !== "string") return fallback; -const expr = c.trim(); -if (!expr) return fallback; -const out = expr.startsWith("#") ? hexColor(expr) : resolveCssColor(host, expr); -if (out) return out; -if (typeof console !== "undefined" && console.warn) { -console.warn(`xy: unresolvable color ${JSON.stringify(expr)}; using fallback`); -} -return fallback; -} -function readTheme(root) { -const text = resolveCssColor(root, "currentColor") || [0.2, 0.2, 0.2, 1]; -const withA = (c, a) => [c[0], c[1], c[2], a]; -const tok = (name) => { -const v = cssToken(root, name); -return v ? resolveCssColor(root, v) || null : null; -}; -return { -bg: tok("--chart-bg"), -grid: tok("--chart-grid") || withA(text, 0.14), -axis: tok("--chart-axis") || withA(text, 0.55), -label: tok("--chart-text") || withA(text, 0.85), -}; -} -function cssColor([r, g, b, a]) { -return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`; -} -const FC_CHROME_CSS = ` -:where(.xy [data-fc-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)} -:where(.xy [data-fc-slot="tooltip"]){background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)} -:where(.xy [data-fc-slot="legend"]){gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)} -:where(.xy [data-fc-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px} -:where(.xy [data-fc-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px} -:where(.xy [data-fc-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box} -:where(.xy [data-fc-slot="colorbar_title"]){font-weight:500} -:where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} -:where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)} -:where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)} -:where(.xy [data-fc-slot="modebar_button"]){width:24px;height:24px;padding:0;border:none;background:transparent;border-radius:3px;color:var(--chart-text,currentColor);cursor:pointer} -:where(.xy [data-fc-modebar-drag-handle]){position:relative;width:22px;margin-right:4px;cursor:move} -:where(.xy [data-fc-modebar-drag-handle])::after{content:"";position:absolute;top:4px;right:-3px;bottom:4px;width:1px;background:rgba(128,128,128,.28);pointer-events:none} -:where(.xy [data-fc-modebar-menu-trigger]){width:auto;min-width:48px;gap:1px;padding:0 4px;font-size:11px;font-variant-numeric:tabular-nums} -:where(.xy [data-fc-modebar-select-trigger]){width:auto;min-width:30px;gap:0;padding:0 2px} -:where(.xy [data-fc-modebar-menu-indicator]){display:flex;transition:transform .15s} -:where(.xy [data-fc-modebar-menu-indicator] svg){width:11px;height:11px} -:where(.xy [data-fc-modebar-menu]){min-width:148px;gap:1px;padding:4px;background:var(--chart-modebar-bg,rgba(255,255,255,.94));border:1px solid rgba(128,128,128,.22);border-radius:7px;box-shadow:0 5px 18px rgba(15,23,42,.18);backdrop-filter:blur(8px)} -:where(.xy [data-fc-modebar-menu-item]){width:100%;height:28px;justify-content:flex-start;padding:0 9px;border-radius:4px;text-align:left;white-space:nowrap} -:where(.xy [data-fc-modebar-menu-item]:hover,.xy [data-fc-modebar-menu-item]:focus-visible){background:var(--chart-modebar-active,rgba(128,128,128,.18));outline:none} -:where(.xy [data-fc-modebar-menu-item][data-fc-separator]){margin-top:3px;border-top:1px solid rgba(128,128,128,.2);border-radius:0 0 4px 4px} -:where(.xy [data-fc-modebar-menu-icon]){display:flex;width:16px;margin-right:7px} -:where(.xy [data-fc-modebar-menu-icon] svg){width:14px;height:14px} -:where(.xy [data-fc-slot="modebar_button"].fc-active){background:var(--chart-modebar-active,rgba(128,128,128,.22))} -:where(.xy [data-fc-slot="selection"]){border:1px solid var(--chart-selection,rgba(90,140,240,.9));background:var(--chart-selection-fill,rgba(90,140,240,.15))} -:where(.xy [data-fc-slot="selection"][data-fc-band="zoom"]){border-color:var(--chart-zoom-selection,rgba(120,120,120,.9));background:var(--chart-zoom-selection-fill,rgba(120,120,120,.12))} -:where(.xy [data-fc-selection-lasso]){fill:var(--chart-selection-fill,rgba(90,140,240,.15));stroke:var(--chart-selection,rgba(90,140,240,.9));stroke-width:1.5;stroke-linejoin:round;pointer-events:none} -:where(.xy [data-fc-selection-lasso-handle]){fill:var(--chart-bg,#fff);stroke:var(--chart-selection,rgba(90,140,240,.9));stroke-width:1.5;cursor:grab;pointer-events:all} -:where(.xy [data-fc-selection-lasso-handle][data-fc-active]){cursor:grabbing;fill:var(--chart-selection,rgba(90,140,240,.9))} -:where(.xy [data-fc-slot="crosshair_x"],.xy [data-fc-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} -:where(.xy [data-fc-slot="tick_label"]){color:var(--chart-text,inherit)} -:where(.xy [data-fc-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px} -:where(.xy [data-fc-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))} -:where(.xy [data-fc-slot="canvas"]){cursor:var(--chart-cursor,crosshair)} -:where(.xy [data-fc-slot="canvas"][data-fc-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)} -:where(.xy [data-fc-slot="canvas"]:focus-visible,.xy [data-fc-slot="modebar_button"]:focus-visible){outline:2px solid var(--chart-focus,#2563eb);outline-offset:2px} -@media (prefers-reduced-motion:reduce){:where(.xy [data-fc-slot="modebar"]){transition-duration:0s!important}} -@media (forced-colors:active){:where(.xy [data-fc-slot="modebar"],.xy [data-fc-slot="tooltip"]){border:1px solid CanvasText}:where(.xy [data-fc-slot="modebar_button"].fc-active){outline:2px solid Highlight}:where(.xy [data-fc-slot="canvas"]:focus){outline:2px solid Highlight}} -`; -function ensureChromeStylesheet(node) { -let root = node && node.getRootNode ? node.getRootNode() : document; -const isShadow = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot; -if (!isShadow && !(root instanceof Document)) root = document; -const scope = isShadow ? root : (root.head || document.head || root.documentElement); -if (!scope || !scope.querySelector) return; -if (scope.querySelector("style[data-xy-chrome]")) return; -const style = document.createElement("style"); -style.setAttribute("data-xy-chrome", ""); -style.textContent = FC_CHROME_CSS; -scope.appendChild(style); -} -function safeCssPaint(host, expr, fallback = [0.5, 0.5, 0.5, 1]) { -const parsed = parseColor(host, expr, fallback); -const color = Array.isArray(parsed) && parsed.length >= 4 && parsed.every(Number.isFinite) -? parsed -: fallback; -return cssColor(color); -} -function niceStep(rough) { -rough = Math.abs(rough); -if (!Number.isFinite(rough) || rough <= 0) return 1; -const mag = Math.pow(10, Math.floor(Math.log10(rough))); -for (const m of [1, 2, 2.5, 5, 10]) { -if (rough <= m * mag * (1 + 1e-12)) return m * mag; -} -return 10 * mag; -} -function linearTicks(lo, hi, target = 6) { -if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 }; -const a = Math.min(lo, hi); -const b = Math.max(lo, hi); -if (a === b) return { ticks: [a], step: 1 }; -const step = niceStep((b - a) / target); -const first = Math.ceil(a / step) * step; -const out = []; -for (let v = first; v <= b + step * 1e-9 && out.length < 200; v += step) { -out.push(Math.abs(v) < step * 1e-9 ? 0 : v); -} -return { ticks: out, step }; -} -function logTicks(lo, hi, target = 6) { -if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: 1 }; -const a = Math.min(lo, hi); -const b = Math.max(lo, hi); -if (a <= 0 || b <= 0) return { ticks: [], step: 1 }; -const e0 = Math.floor(Math.log10(a)); -const e1 = Math.ceil(Math.log10(b)); -const span = Math.max(1, e1 - e0); -const mults = span <= Math.max(2, target) ? [1, 2, 5] : [1]; -const out = []; -const labels = []; -const labelEvery = Math.max(1, Math.ceil((e1 - e0 + 1) / Math.max(1, target))); -for (let e = e0; e <= e1 && out.length < 200; e++) { -const base = Math.pow(10, e); -for (const m of mults) { -const v = m * base; -if (v >= a * (1 - 1e-12) && v <= b * (1 + 1e-12)) { -out.push(v); -if (m === 1 && (e - e0) % labelEvery === 0) labels.push(v); -} -if (out.length >= 200) break; -} -} -return { ticks: out, labels: labels.length ? labels : out, step: 1, log: true }; -} -function categoryTicks(lo, hi, categories, target = 6) { -if (!categories || !categories.length) return { ticks: [], step: 1 }; -const start = Math.max(0, Math.ceil(Math.min(lo, hi))); -const stop = Math.min(categories.length - 1, Math.floor(Math.max(lo, hi))); -if (stop < start) return { ticks: [], step: 1 }; -const visible = stop - start + 1; -const step = Math.max(1, Math.ceil(visible / Math.max(1, target))); -const out = []; -for (let v = start; v <= stop && out.length < 200; v += step) out.push(v); -return { ticks: out, step }; -} -const MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }; -const TIME_STEPS = [ -1, 2, 5, 10, 20, 50, 100, 200, 500, -MS.s, 2 * MS.s, 5 * MS.s, 10 * MS.s, 15 * MS.s, 30 * MS.s, -MS.m, 2 * MS.m, 5 * MS.m, 10 * MS.m, 15 * MS.m, 30 * MS.m, -MS.h, 2 * MS.h, 3 * MS.h, 6 * MS.h, 12 * MS.h, -MS.d, 2 * MS.d, 7 * MS.d, 14 * MS.d, -]; -function timeTicks(lo, hi, target = 6) { -if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { ticks: [], step: MS.d }; -const a = Math.min(lo, hi); -const b = Math.max(lo, hi); -const span = b - a; -const rough = span / target; -if (rough > 14 * MS.d) return calendarTicks(a, b, rough); -let step = TIME_STEPS[TIME_STEPS.length - 1]; -for (const s of TIME_STEPS) { -if (s >= rough) { step = s; break; } -} -const first = Math.ceil(a / step) * step; -const out = []; -for (let v = first; v <= b && out.length < 200; v += step) out.push(v); -return { ticks: out, step }; -} -function calendarTicks(lo, hi, rough) { -const monthsRough = rough / (30 * MS.d); -const monthSteps = [1, 2, 3, 6, 12, 24, 60, 120]; -let stepM = monthSteps[monthSteps.length - 1]; -for (const s of monthSteps) { -if (s >= monthsRough) { stepM = s; break; } -} -const d = new Date(lo); -let y = d.getUTCFullYear(); -let m = d.getUTCMonth(); -m = Math.ceil(m / stepM) * stepM; -const out = []; -for (;;) { -const t = Date.UTC(y + Math.floor(m / 12), m % 12, 1); -if (t > hi) break; -if (t >= lo) out.push(t); -m += stepM; -if (out.length > 1000) break; -} -return { ticks: out, step: stepM * 30 * MS.d }; -} -function fmtTime(ms, step) { -const d = new Date(ms); -const pad = (n, w = 2) => String(n).padStart(w, "0"); -if (step >= 28 * MS.d) { -const mo = d.getUTCMonth(); -return mo === 0 ? String(d.getUTCFullYear()) -: `${d.toLocaleString("en", { month: "short", timeZone: "UTC" })} ${d.getUTCFullYear()}`; -} -if (step >= MS.d) return `${d.toLocaleString("en", { month: "short", timeZone: "UTC" })} ${pad(d.getUTCDate())}`; -if (step >= MS.m) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; -if (step >= MS.s) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; -return `${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(), 3)}`; -} -function fmtLinear(v, step) { -const av = Math.abs(v); -if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e"); -let dec = step ? Math.max(0, Math.ceil(-Math.log10(Math.abs(step)))) : 0; -while (dec < 8 && Math.abs(Number(step.toFixed(dec)) - step) > Math.abs(step) / 1000) dec++; -return v.toFixed(Math.min(dec, 8)); -} -function fmtCategory(v, categories) { -const i = Math.round(v); -return i >= 0 && i < categories.length ? String(categories[i]) : ""; -} -function fmtNumberSpec(v, format) { -if (typeof format !== "string" || !Number.isFinite(Number(v))) return null; -const percent = format.endsWith("%"); -const raw = percent ? format.slice(0, -1) : format; -const match = raw.match(/^(,)?\.([0-9]+)f?$/); -if (!match) return null; -const digits = Number(match[2]); -const value = percent ? Number(v) * 100 : Number(v); -const text = match[1] -? value.toLocaleString(undefined, { -minimumFractionDigits: digits, -maximumFractionDigits: digits, -}) -: value.toFixed(digits); -return percent ? `${text}%` : text; -} -function fmtTimeSpec(ms, format) { -if (typeof format !== "string") return null; -const d = new Date(ms); -if (!Number.isFinite(d.getTime())) return null; -const pad = (n, w = 2) => String(n).padStart(w, "0"); -const shortMonth = d.toLocaleString("en", { month: "short", timeZone: "UTC" }); -const longMonth = d.toLocaleString("en", { month: "long", timeZone: "UTC" }); -return format.replace(/%[YmdHMSbB]/g, (token) => { -switch (token) { -case "%Y": return String(d.getUTCFullYear()); -case "%m": return pad(d.getUTCMonth() + 1); -case "%d": return pad(d.getUTCDate()); -case "%H": return pad(d.getUTCHours()); -case "%M": return pad(d.getUTCMinutes()); -case "%S": return pad(d.getUTCSeconds()); -case "%b": return shortMonth; -case "%B": return longMonth; -default: return token; -} -}); -} -function fmtAxis(axis, v, tickStep) { -if (axis && axis.kind === "category") return fmtCategory(v, axis.categories || []); -if (axis && axis.kind === "time") return fmtTimeSpec(v, axis.format) || fmtTime(v, tickStep); -const formatted = fmtNumberSpec(v, axis && axis.format); -if (axis && axis.scale === "log" && Number(v) > 0 && Number(v) < 1 && formatted === "0") { -return fmtLinear(v, tickStep); -} -return formatted || fmtLinear(v, tickStep); -} -function fmtValue(v, kind) { -if (kind === "time_ms") { -const d = new Date(v); -return d.toISOString().replace("T", " ").replace(".000Z", "Z"); -} -if (typeof v === "string") return v; -const n = Number(v); -if (!Number.isFinite(n)) return String(v); -if (n === 0) return "0"; -const av = Math.abs(n); -if (av >= 1e6 || av < 1e-4) return n.toExponential(3); -return (Math.round(n * 1e4) / 1e4).toString(); -} -function compile(gl, type, src) { -const sh = gl.createShader(type); -gl.shaderSource(sh, src); -gl.compileShader(sh); -if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { -throw new Error("shader compile: " + gl.getShaderInfoLog(sh) + "\n" + src); -} -return sh; -} -const ATTR_SLOTS = { -ax: 0, ay: 1, -ax0: 0, ax1: 1, ay0: 2, ay1: 3, ax2: 4, ay2: 5, ab0: 4, ab1: 5, -a_pos: 0, a_v1: 1, a_v0: 2, -a_corner: 0, -a_cval: 6, a_sval: 7, a_sel: 8, a_dval: 9, -a_len0: 10, a_len1: 11, -a_dash0: 10, a_dashDir: 11, -}; -function makeProgram(gl, vs, fs) { -const p = gl.createProgram(); -const vsh = compile(gl, gl.VERTEX_SHADER, vs); -const fsh = compile(gl, gl.FRAGMENT_SHADER, fs); -gl.attachShader(p, vsh); -gl.attachShader(p, fsh); -for (const [name, slot] of Object.entries(ATTR_SLOTS)) { -gl.bindAttribLocation(p, slot, name); -} -gl.linkProgram(p); -const ok = gl.getProgramParameter(p, gl.LINK_STATUS); -const info = gl.getProgramInfoLog(p); -gl.detachShader(p, vsh); -gl.detachShader(p, fsh); -gl.deleteShader(vsh); -gl.deleteShader(fsh); -if (!ok) { -gl.deleteProgram(p); -throw new Error("program link: " + info); -} -p._u = Object.create(null); -return p; -} -function uniformOf(gl, prog, name) { -let loc = prog._u[name]; -if (loc === undefined) { -loc = gl.getUniformLocation(prog, name); -prog._u[name] = loc; -} -return loc; -} -const AXIS_GLSL = ` -float fcDecode(float encoded, vec2 meta) { - return encoded / max(abs(meta.y), 1e-30) + meta.x; -} -float fcAxisCoord(float encoded, vec2 meta, int mode) { - float value = fcDecode(encoded, meta); - if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; - return value; -} -float fcMap(float encoded, vec2 map, vec2 meta, int mode) { - return fcAxisCoord(encoded, meta, mode) * map.x + map.y; -} -float fcViewCoord(float value, int mode) { - if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; - return value; -} -float fcViewValue(float coord, int mode) { - if (mode == 1) return pow(10.0, coord); - return coord; -} -`; -const POINT_VS = `#version 300 es -in float ax; in float ay; in float a_cval; in float a_sval; in float a_sel; in float a_dval; -uniform vec2 u_xmap; uniform vec2 u_ymap; -uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; -uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; -uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; -uniform float u_selectedOpacity; uniform float u_unselectedOpacity; -out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; -${AXIS_GLSL} -void main() { - gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); - float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; - gl_PointSize = sz * u_dpr; - v_ptSize = sz * u_dpr; - v_sel = a_sel; - // continuous: coord = value in [0,1]; categorical: center of texel a_cval. - v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; - // Local log-density LUT coord (drill handoff, §5): lets freshly drilled - // points wear the density colormap so the texture->points swap is seamless. - v_dval = a_dval; - // Unselected marks dim when a selection is active (§34 selected/unselected styling). - v_dim = u_selActive == 1 ? mix(u_unselectedOpacity, u_selectedOpacity, step(0.5, a_sel)) : 1.0; -}`; -const MARKER_SDF_GLSL = ` -float fcSegmentDistance(vec2 p, vec2 a, vec2 b) { - vec2 e = b - a; - return length(p - a - e * clamp(dot(p - a, e) / dot(e, e), 0.0, 1.0)); -} -float fcTriangleDistance(vec2 p, vec2 a, vec2 b, vec2 c) { - float dist = min(fcSegmentDistance(p, a, b), - min(fcSegmentDistance(p, b, c), fcSegmentDistance(p, c, a))); - float c0 = (b.x-a.x)*(p.y-a.y) - (b.y-a.y)*(p.x-a.x); - float c1 = (c.x-b.x)*(p.y-b.y) - (c.y-b.y)*(p.x-b.x); - float c2 = (a.x-c.x)*(p.y-c.y) - (a.y-c.y)*(p.x-c.x); - bool inside = (c0 >= 0.0 && c1 >= 0.0 && c2 >= 0.0) || - (c0 <= 0.0 && c1 <= 0.0 && c2 <= 0.0); - return inside ? -dist : dist; -} -float fcPentagonDistance(vec2 p) { - // Path.unit_regular_polygon(5), then Matplotlib's 0.5 marker transform. - vec2 a = vec2(0.0, -0.5); - vec2 b = vec2(-0.475528258, -0.154508497); - vec2 c = vec2(-0.293892626, 0.404508497); - vec2 d = vec2(0.293892626, 0.404508497); - vec2 e = vec2(0.475528258, -0.154508497); - float dist = min(min(fcSegmentDistance(p, a, b), fcSegmentDistance(p, b, c)), - min(min(fcSegmentDistance(p, c, d), fcSegmentDistance(p, d, e)), - fcSegmentDistance(p, e, a))); - float c0 = (b.x-a.x)*(p.y-a.y) - (b.y-a.y)*(p.x-a.x); - float c1 = (c.x-b.x)*(p.y-b.y) - (c.y-b.y)*(p.x-b.x); - float c2 = (d.x-c.x)*(p.y-c.y) - (d.y-c.y)*(p.x-c.x); - float c3 = (e.x-d.x)*(p.y-d.y) - (e.y-d.y)*(p.x-d.x); - float c4 = (a.x-e.x)*(p.y-e.y) - (a.y-e.y)*(p.x-e.x); - bool inside = (c0 >= 0.0 && c1 >= 0.0 && c2 >= 0.0 && c3 >= 0.0 && c4 >= 0.0) || - (c0 <= 0.0 && c1 <= 0.0 && c2 <= 0.0 && c3 <= 0.0 && c4 <= 0.0); - return inside ? -dist : dist; -} -float fcMarkerSdf(vec2 d, int shape) { - if (shape == 1) return max(abs(d.x), abs(d.y)) - 0.5; // square - if (shape == 2) return (abs(d.x) + abs(d.y)) - 0.5; // diamond - if (shape == 4) { // cross / plus - vec2 a = abs(d); - return min(max(a.x - 0.17, a.y - 0.5), max(a.x - 0.5, a.y - 0.17)); - } - if (shape == 5) { // regular hexagon (pointy top) - const vec3 k = vec3(-0.866025404, 0.5, 0.577350269); - vec2 p = abs(vec2(d.y, d.x)); - p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; - p -= vec2(clamp(p.x, -k.z * 0.5, k.z * 0.5), 0.5); - return length(p) * sign(p.y); - } - if (shape == 6) return fcPentagonDistance(d); // exact regular pentagon - if (shape == 7) { // five-pointed star (apex up) - const float rf = 0.45; - const vec2 k1 = vec2(0.809016994, -0.587785252); - const vec2 k2 = vec2(-k1.x, k1.y); - vec2 p = vec2(abs(d.x), -d.y); - p -= 2.0 * max(dot(k1, p), 0.0) * k1; - p -= 2.0 * max(dot(k2, p), 0.0) * k2; - p = vec2(abs(p.x), p.y - 0.5); - vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0.0, 1.0); - float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, 0.5); - return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); - } - if (shape == 3 || shape == 8 || shape == 9 || shape == 10) { // Matplotlib triangle path - vec2 q = d; - if (shape == 8) q = -d; - if (shape == 9) q = vec2(d.y, -d.x); - if (shape == 10) q = vec2(-d.y, d.x); - return fcTriangleDistance(q, vec2(0.0, -0.5), vec2(-0.5, 0.5), vec2(0.5, 0.5)); - } - if (shape == 11) { // diagonal x - vec2 q = vec2(d.x + d.y, d.y - d.x) * 0.707106781; - vec2 a = abs(q); - return min(max(a.x - 0.17, a.y - 0.5), max(a.x - 0.5, a.y - 0.17)); - } - if (shape == 13) return max(abs(d.x), abs(d.y)) - 0.5; // snapped pixel - if (shape == 14) return (abs(d.x) / 0.6 + abs(d.y)) - 0.5; // thin diamond - return length(d) - 0.5; // circle -}`; -const POINT_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform sampler2D u_dlut; uniform float u_dblend; -uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; -uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; -in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in float v_sel; -out vec4 outColor; -${MARKER_SDF_GLSL} -void main() { - vec2 d = gl_PointCoord - 0.5; - float sd; - bool lineMarker = u_symbol == 15 || u_symbol == 16; - if (lineMarker) { - vec2 q = u_symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; - float halfWidth = max(u_ptStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); - vec2 a = abs(q); - sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); - } else { - sd = fcMarkerSdf(d, u_symbol); - } - float aa = fwidth(sd) + 1e-4; - float shapeCov = clamp(0.5 - sd / aa, 0.0, 1.0); - if (shapeCov <= 0.001) discard; - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; - // Drill handoff (§5): near the density boundary, paint by local density with - // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). - if (u_dblend > 0.001) { - vec3 drgb = texture(u_dlut, vec2(clamp(v_dval, 0.0, 1.0), 0.5)).rgb; - rgb = mix(rgb, drgb, u_dblend); - } - // §34 selected/unselected recolor: when a selection is active, tint each point - // toward its state color (.a is the mix weight; 0 = keep native color). - if (u_selActive == 1) { - vec4 sc = v_sel > 0.5 ? u_selColor : u_unselColor; - rgb = mix(rgb, sc.rgb, sc.a); - } - float fillAlpha = u_opacity; - vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill - vec4 strokePx = u_ptStrokeFace == 1 ? px : u_ptStroke; - if (lineMarker) { - outColor = strokePx * (shapeCov * v_dim); - return; - } - if (u_ptStrokeWidth > 0.0) { - float sw = u_ptStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units - // The supplied point size includes the edge. Recover Matplotlib's path - // boundary half a stroke inside it, then source-over the centered stroke. - float pathCov = clamp(0.5 - (sd + sw * 0.5) / aa, 0.0, 1.0); - float innerCov = clamp(0.5 - (sd + sw) / aa, 0.0, 1.0); - float strokeCov = max(shapeCov - innerCov, 0.0); - vec4 fillLayer = px * pathCov; - vec4 strokeLayer = strokePx * strokeCov; - px = strokeLayer + fillLayer * (1.0 - strokeLayer.a); - outColor = px * v_dim; - return; - } - outColor = px * (shapeCov * v_dim); -}`; -const POINT_SIMPLE_VS = `#version 300 es -in float ax; in float ay; -uniform vec2 u_xmap; uniform vec2 u_ymap; -uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; -uniform float u_size; uniform float u_dpr; -${AXIS_GLSL} -void main() { - gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); - gl_PointSize = u_size * u_dpr; -}`; -const POINT_SIMPLE_FS = `#version 300 es -precision highp float; -uniform vec4 u_color; -out vec4 outColor; -void main() { - float sd = length(gl_PointCoord - 0.5) - 0.5; - float aa = fwidth(sd) + 1e-4; - float coverage = clamp(0.5 - sd / aa, 0.0, 1.0); - if (coverage <= 0.001) discard; - outColor = vec4(u_color.rgb * u_color.a, u_color.a) * coverage; -}`; -const PICK_VS = `#version 300 es -in float ax; in float ay; in float a_sval; -uniform vec2 u_xmap; uniform vec2 u_ymap; -uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; -uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform float u_dpr; -flat out int v_id; -${AXIS_GLSL} -void main() { - gl_Position = vec4(fcMap(ax, u_xmap, u_xmeta, u_xmode), fcMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); - float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size; - gl_PointSize = max(sz, 6.0) * u_dpr; // enlarge hit target - v_id = gl_VertexID; -}`; -const PICK_FS = `#version 300 es -precision highp float; precision highp int; -uniform int u_pick_base; -flat in int v_id; -out vec4 outColor; -void main() { - vec2 d = gl_PointCoord - 0.5; - if (length(d) > 0.5) discard; - int id = u_pick_base + v_id; - outColor = vec4( - float(id & 255) / 255.0, - float((id >> 8) & 255) / 255.0, - float((id >> 16) & 255) / 255.0, - float((id >> 24) & 255) / 255.0 - ); -}`; -const GRID_VS = `#version 300 es -in vec2 a_corner; -uniform vec4 u_view; // x0,x1,y0,y1 -uniform int u_xmode; uniform int u_ymode; -out vec2 v_data; -${AXIS_GLSL} -void main() { - gl_Position = vec4(a_corner * 2.0 - 1.0, 0.0, 1.0); - float x = mix(fcViewCoord(u_view.x, u_xmode), fcViewCoord(u_view.y, u_xmode), a_corner.x); - float y = mix(fcViewCoord(u_view.z, u_ymode), fcViewCoord(u_view.w, u_ymode), a_corner.y); - v_data = vec2(fcViewValue(x, u_xmode), fcViewValue(y, u_ymode)); -}`; -const DENSITY_FS = `#version 300 es -precision highp float; -uniform sampler2D u_grid; uniform sampler2D u_lut; -uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1 -uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor; -in vec2 v_data; -out vec4 outColor; -void main() { - vec2 uv = vec2((v_data.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), - (v_data.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); - if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; - float t = texture(u_grid, uv).r; - if (t <= 0.0) discard; - vec4 paint = u_constantColor == 1 - ? u_color - : texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5)); - vec3 rgb = paint.rgb; - float alpha = u_opacity * paint.a * clamp(t * 1.35, 0.0, 1.0); - if (alpha <= 0.01) discard; - outColor = vec4(rgb * alpha, alpha); -}`; -const HEATMAP_FS = `#version 300 es -precision highp float; -uniform sampler2D u_grid; uniform sampler2D u_lut; -uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1 -uniform float u_opacity; -uniform int u_truecolor; -in vec2 v_data; -out vec4 outColor; -void main() { - vec2 uv = vec2((v_data.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), - (v_data.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); - if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; - vec4 sampled = texture(u_grid, uv); - if (u_truecolor == 1) { - float alpha = sampled.a * u_opacity; - if (alpha <= 0.0) discard; - outColor = vec4(sampled.rgb * alpha, alpha); - return; - } - float raw = sampled.r; - if (raw <= 0.0) discard; - float t = clamp((raw * 255.0 - 1.0) / 254.0, 0.0, 1.0); - vec3 rgb = texture(u_lut, vec2(t, 0.5)).rgb; - outColor = vec4(rgb * u_opacity, u_opacity); -}`; -const LINE_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; -uniform int u_colorMode; -uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; -in float a_len0; in float a_len1; -out float v_off; out float v_dash; -const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); -${AXIS_GLSL} -void main() { - vec2 p0 = vec2(fcMap(ax0, u_xmap, u_xmeta, u_xmode), fcMap(ay0, u_ymap, u_ymeta, u_ymode)); - vec2 p1 = vec2(fcMap(ax1, u_xmap, u_xmeta, u_xmode), fcMap(ay1, u_ymap, u_ymeta, u_ymode)); - vec2 pix0 = (p0 * 0.5 + 0.5) * u_res; - vec2 pix1 = (p1 * 0.5 + 0.5) * u_res; - vec2 dir = pix1 - pix0; - float len = max(length(dir), 1e-6); - dir /= len; - vec2 n = vec2(-dir.y, dir.x); - vec2 c = corners[gl_VertexID]; - float half_w = u_width * 0.5 + 0.5; - vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; - gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); - v_off = c.y * half_w; - // Cumulative screen-space arc length at this fragment (device px), fed from - // CPU-computed per-vertex lengths so dashes stay continuous across segments - // and constant on screen through zoom. - v_dash = mix(a_len0, a_len1, c.x); -}`; -const LINE_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; -uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_dash; -out vec4 outColor; -void main() { - float half_w = u_width * 0.5; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; - if (u_dashCount > 0) { - float m = mod(v_dash, u_dashPeriod); - float acc = 0.0; - float on = 0.0; - for (int i = 0; i < 8; i++) { - if (i >= u_dashCount) break; - float next = acc + u_dashArr[i]; - if (m < next) { - // 0.6px feather at each dash start/end so edges aren't aliased. - float d = min(m - acc, next - m); - on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0); - break; - } - acc = next; - } - alpha *= on; - } - if (alpha <= 0.001) discard; - outColor = vec4(u_color.rgb * alpha, alpha); -}`; -const SEGMENT_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; -in float a_dash0; in float a_dashDir; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; -uniform int u_colorMode; -uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; -uniform int u_x0mode; uniform int u_x1mode; uniform int u_y0mode; uniform int u_y1mode; -out float v_off; out float v_cval; out float v_dash; -const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); -${AXIS_GLSL} -void main() { - vec2 p0 = vec2(fcMap(ax0, u_xmap, u_x0meta, u_x0mode), fcMap(ay0, u_ymap, u_y0meta, u_y0mode)); - vec2 p1 = vec2(fcMap(ax1, u_xmap, u_x1meta, u_x1mode), fcMap(ay1, u_ymap, u_y1meta, u_y1mode)); - vec2 pix0 = (p0 * 0.5 + 0.5) * u_res; - vec2 pix1 = (p1 * 0.5 + 0.5) * u_res; - vec2 dir = pix1 - pix0; - float len = max(length(dir), 1e-6); - dir /= len; - vec2 n = vec2(-dir.y, dir.x); - vec2 c = corners[gl_VertexID]; - float half_w = u_width * 0.5 + 0.5; - vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; - gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); - v_off = c.y * half_w; - v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; - v_dash = a_dash0 + c.x * len * a_dashDir; -}`; -const SEGMENT_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; -uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_cval; in float v_dash; -out vec4 outColor; -void main() { - float half_w = u_width * 0.5; - vec3 rgb = u_colorMode != 0 ? texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb : u_color.rgb; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; - if (u_dashCount > 0) { - float m = mod(v_dash, u_dashPeriod); - float acc = 0.0; - float on = 0.0; - for (int i = 0; i < 8; i++) { - if (i >= u_dashCount) break; - float next = acc + u_dashArr[i]; - if (m < next) { on = (i % 2 == 0) ? 1.0 : 0.0; break; } - acc = next; - } - alpha *= on; - } - if (alpha <= 0.001) discard; - outColor = vec4(rgb * alpha, alpha); -}`; -const MESH_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; in float ax2; in float ay2; in float a_cval; -uniform vec2 u_xmap; uniform vec2 u_ymap; -uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_x2meta; -uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_y2meta; -uniform int u_x0mode; uniform int u_x1mode; uniform int u_x2mode; -uniform int u_y0mode; uniform int u_y1mode; uniform int u_y2mode; -uniform int u_colorMode; -out float v_cval; out vec3 v_bary; -${AXIS_GLSL} -void main() { - int vertex = gl_VertexID % 3; - float x = vertex == 0 ? ax0 : (vertex == 1 ? ax1 : ax2); - float y = vertex == 0 ? ay0 : (vertex == 1 ? ay1 : ay2); - vec2 xm = vertex == 0 ? u_x0meta : (vertex == 1 ? u_x1meta : u_x2meta); - vec2 ym = vertex == 0 ? u_y0meta : (vertex == 1 ? u_y1meta : u_y2meta); - int xmode = vertex == 0 ? u_x0mode : (vertex == 1 ? u_x1mode : u_x2mode); - int ymode = vertex == 0 ? u_y0mode : (vertex == 1 ? u_y1mode : u_y2mode); - gl_Position = vec4(fcMap(x, u_xmap, xm, xmode), fcMap(y, u_ymap, ym, ymode), 0.0, 1.0); - v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; - v_bary = vertex == 0 ? vec3(1.,0.,0.) : (vertex == 1 ? vec3(0.,1.,0.) : vec3(0.,0.,1.)); -}`; -const MESH_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform vec4 u_stroke; uniform float u_strokeWidth; -in float v_cval; in vec3 v_bary; -out vec4 outColor; -void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb; - vec4 fill = vec4(rgb * u_opacity, u_opacity); - if (u_strokeWidth > 0.0) { - float edge = min(v_bary.x, min(v_bary.y, v_bary.z)); - float coverage = smoothstep(0.0, max(fwidth(edge) * u_strokeWidth, 1e-5), edge); - outColor = mix(u_stroke, fill, coverage); - } else { - outColor = fill; - } -}`; -const GRAD_GLSL = ` -uniform int u_gradMode; uniform int u_gradDir; uniform int u_gradCount; -uniform float u_gradPos[8]; uniform vec4 u_gradColor[8]; -vec4 fcGradSample(float t) { - vec4 c0 = u_gradColor[0]; float p0 = u_gradPos[0]; - if (t <= p0) return c0; - for (int i = 1; i < 8; i++) { - if (i >= u_gradCount) break; - float p1 = u_gradPos[i]; vec4 c1 = u_gradColor[i]; - if (t <= p1) return mix(c0, c1, (t - p0) / max(p1 - p0, 1e-6)); - p0 = p1; c0 = c1; - } - return c0; -} -float fcGradT(float markT, vec2 res) { - float t; - if (u_gradMode == 2) { - vec2 f = gl_FragCoord.xy / max(res, vec2(1.0)); - t = u_gradDir == 0 ? 1.0 - f.y : u_gradDir == 1 ? f.y : u_gradDir == 2 ? 1.0 - f.x : f.x; - } else { - t = u_gradDir == 0 ? 1.0 - markT : markT; - } - return clamp(t, 0.0, 1.0); -}`; -const AREA_VS = `#version 300 es -in float ax0; in float ax1; in float ay0; in float ay1; in float ab0; in float ab1; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_bmap; -uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform vec2 u_bmeta; -uniform int u_xmode; uniform int u_ymode; -out float v_top; out float v_base; out float v_pos; -const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); -${AXIS_GLSL} -void main() { - vec2 c = corners[gl_VertexID]; - float x0 = fcMap(ax0, u_xmap, u_xmeta, u_xmode); - float x1 = fcMap(ax1, u_xmap, u_xmeta, u_xmode); - float y0 = fcMap(ay0, u_ymap, u_ymeta, u_ymode); - float y1 = fcMap(ay1, u_ymap, u_ymeta, u_ymode); - float b0 = fcMap(ab0, u_bmap, u_bmeta, u_ymode); - float b1 = fcMap(ab1, u_bmap, u_bmeta, u_ymode); - float top = mix(y0, y1, c.x); - float base = mix(b0, b1, c.x); - float clipY = mix(base, top, c.y); - // Carry the curve top, baseline, and this fragment's Y *separately* (each is - // linear in x and continuous across segments); the fragment divides them for - // a true per-column height fraction. Interpolating the ratio itself (the old - // c.y) facets over the slanted-top quad and streaks — this doesn't, and the - // fill stays evenly saturated at the curve whatever its height. - v_top = top; - v_base = base; - v_pos = clipY; - gl_Position = vec4(mix(x0, x1, c.x), clipY, 0.0, 1.0); -}`; -const AREA_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; -uniform vec2 u_res; -in float v_top; in float v_base; in float v_pos; -out vec4 outColor; -${GRAD_GLSL} -void main() { - vec4 premult = vec4(u_color.rgb * u_color.a, u_color.a); - if (u_gradMode != 0) { - // 0 at the baseline, 1 exactly at the curve — even at the curve everywhere. - float denom = v_top - v_base; - float markT = clamp((v_pos - v_base) / (abs(denom) > 1e-6 ? denom : 1e-6), 0.0, 1.0); - // Compose the mark opacity (premultiplied) over the gradient sample. - premult = fcGradSample(fcGradT(markT, u_res)) * u_color.a; - } - if (premult.a <= 0.001) discard; - outColor = premult; -}`; -const RECT_VS = `#version 300 es -in float ax0; in float ax1; in float ay0; in float ay1; -uniform vec2 u_x0map; uniform vec2 u_x1map; uniform vec2 u_y0map; uniform vec2 u_y1map; -uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; -uniform int u_xmode; uniform int u_ymode; -uniform vec4 u_edgePad; -uniform vec2 u_res; -in float a_cval; uniform int u_colorMode; -out float v_lutCoord; -out vec2 v_local; out vec2 v_half; out float v_t; -const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); -${AXIS_GLSL} -void main() { - vec2 c = corners[gl_VertexID]; - float x0 = fcMap(ax0, u_x0map, u_x0meta, u_xmode) + u_edgePad.x; - float x1 = fcMap(ax1, u_x1map, u_x1meta, u_xmode) + u_edgePad.y; - float y0 = fcMap(ay0, u_y0map, u_y0meta, u_ymode) + u_edgePad.z; - float y1 = fcMap(ay1, u_y1map, u_y1meta, u_ymode) + u_edgePad.w; - v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; - // Pixel-space local frame for the rounded-corner/stroke SDF (v_half is - // constant across the quad; v_local interpolates to the fragment offset). - vec2 pA = (vec2(x0, y0) * 0.5 + 0.5) * u_res; - vec2 pB = (vec2(x1, y1) * 0.5 + 0.5) * u_res; - v_half = abs(pB - pA) * 0.5; - v_local = mix(pA, pB, c) - (pA + pB) * 0.5; - v_t = c.y; - gl_Position = vec4(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0); -}`; -const BAR_VS = `#version 300 es -in float a_pos; in float a_v0; in float a_v1; in float a_cval; -uniform vec2 u_pmap; uniform vec2 u_v0map; uniform vec2 u_v1map; -uniform vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; -uniform int u_pmode; uniform int u_vmode; -uniform float u_width; uniform int u_orientation; uniform int u_v0Mode; uniform float u_v0Const; -uniform float u_v0EdgePad; -uniform vec2 u_res; -uniform int u_colorMode; -out float v_lutCoord; -out vec2 v_local; out vec2 v_half; out float v_t; -const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); -${AXIS_GLSL} -void main() { - vec2 c = corners[gl_VertexID]; - float p = fcMap(a_pos, u_pmap, u_pmeta, u_pmode); - float halfW = abs(u_width * u_pmap.x) * 0.5; - float v0 = (u_v0Mode == 0 ? u_v0Const : fcMap(a_v0, u_v0map, u_v0meta, u_vmode)) + u_v0EdgePad; - float v1 = fcMap(a_v1, u_v1map, u_v1meta, u_vmode); - v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; - vec2 clipA, clipB; - if (u_orientation == 0) { - clipA = vec2(p - halfW, v0); clipB = vec2(p + halfW, v1); - gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); - v_t = c.y; - } else { - clipA = vec2(v0, p - halfW); clipB = vec2(v1, p + halfW); - gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); - v_t = c.x; - } - // Pixel-space local frame for the rounded-corner/stroke SDF; v_t runs along - // the value axis (0 at the base, 1 at the bar tip) for mark-space gradients. - vec2 pA = (clipA * 0.5 + 0.5) * u_res; - vec2 pB = (clipB * 0.5 + 0.5) * u_res; - v_half = abs(pB - pA) * 0.5; - v_local = vec2(mix(pA.x, pB.x, c.x), mix(pA.y, pB.y, c.y)) - (pA + pB) * 0.5; -}`; -const RECT_FS = `#version 300 es -precision highp float; precision highp int; -uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; -uniform vec2 u_radius; uniform float u_strokeWidth; uniform vec4 u_stroke; -uniform vec2 u_res; -in float v_lutCoord; -in vec2 v_local; in vec2 v_half; in float v_t; -out vec4 outColor; -${GRAD_GLSL} -void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; - vec4 premult = vec4(rgb * u_color.a, u_color.a); - // Compose the mark opacity (u_color.a) over the gradient — premultiplied, so - // one scalar multiply fades every stop, including a fade-to-transparent. - if (u_gradMode != 0) premult = fcGradSample(fcGradT(v_t, u_res)) * u_color.a; - if (u_radius.x > 0.0 || u_radius.y > 0.0 || u_strokeWidth > 0.0) { - // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so - // corner_radius=(6, 0) rounds only the value end of the bar. On the - // straight sides the SDF reduces to |local|-half independent of r, so - // differing radii meet with no seam. - float r = min(v_t > 0.5 ? u_radius.x : u_radius.y, min(v_half.x, v_half.y)); - vec2 q = abs(v_local) - (v_half - vec2(r)); - float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; - float aa = 0.75; - if (u_strokeWidth > 0.0) { - float inner = 1.0 - smoothstep(-aa, aa, d + u_strokeWidth); - premult = mix(u_stroke, premult, inner); - } - premult *= 1.0 - smoothstep(-aa, aa, d); - } - if (premult.a <= 0.001) discard; - outColor = premult; -}`; -function fcMonotoneTangents(x, y, n) { -const d = new Float64Array(n - 1); -const m = new Float64Array(n); -for (let i = 0; i < n - 1; i++) { -const dx = x[i + 1] - x[i]; -d[i] = dx > 0 ? (y[i + 1] - y[i]) / dx : 0; -} -m[0] = d[0]; -m[n - 1] = d[n - 2]; -for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) * 0.5; -for (let i = 0; i < n - 1; i++) { -if (d[i] === 0) { m[i] = 0; m[i + 1] = 0; continue; } -const a = m[i] / d[i]; -const b = m[i + 1] / d[i]; -const s = a * a + b * b; -if (s > 9) { -const t = 3 / Math.sqrt(s); -m[i] = t * a * d[i]; -m[i + 1] = t * b * d[i]; -} -} -return m; -} -function fcSmoothResample(x, y, extra, n, maxOut) { -if (n < 3) return null; -const sub = Math.max(1, Math.min(16, Math.floor(maxOut / n))); -if (sub <= 1) return null; -for (let i = 0; i < n; i++) { -if (!Number.isFinite(x[i]) || !Number.isFinite(y[i])) return null; -if (i > 0 && x[i] < x[i - 1]) return null; -if (extra && !Number.isFinite(extra[i])) return null; -} -const my = fcMonotoneTangents(x, y, n); -const me = extra ? fcMonotoneTangents(x, extra, n) : null; -const outN = (n - 1) * sub + 1; -const ox = new Float32Array(outN); -const oy = new Float32Array(outN); -const oe = extra ? new Float32Array(outN) : null; -let k = 0; -for (let i = 0; i < n - 1; i++) { -const h = x[i + 1] - x[i]; -for (let s = 0; s < sub; s++) { -const t = s / sub; -ox[k] = x[i] + h * t; -if (h > 0) { -const t2 = t * t; -const t3 = t2 * t; -const h00 = 2.0 * t3 - 3.0 * t2 + 1.0; -const h10 = t3 - 2.0 * t2 + t; -const h01 = -2.0 * t3 + 3.0 * t2; -const h11 = t3 - t2; -oy[k] = h00 * y[i] + h10 * h * my[i] + h01 * y[i + 1] + h11 * h * my[i + 1]; -if (oe) oe[k] = h00 * extra[i] + h10 * h * me[i] + h01 * extra[i + 1] + h11 * h * me[i + 1]; -} else { -oy[k] = y[i]; -if (oe) oe[k] = extra[i]; -} -k++; -} -} -ox[k] = x[n - 1]; -oy[k] = y[n - 1]; -if (oe) oe[k] = extra[n - 1]; -return { x: ox, y: oy, extra: oe, n: outN }; -} -const LOD_DIRECT_POINT_BUDGET = 200000; -const LOD_DRILL_EXIT_FACTOR = 1.15; -function lodFade(view, start, duration = 140) { -if (start === undefined || start === null || duration <= 0 || view._prefersReducedMotion()) { -return 1; -} -const t = Math.min(1, Math.max(0, (view._now() - start) / duration)); -return t * t * (3 - 2 * t); -} -function lodDecodeLogU8(buf, maxVal) { -const u8 = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); -const out = new Float32Array(u8.length); -const denom = Math.log1p(Math.max(0, maxVal || 0)); -if (denom > 0) { -for (let i = 0; i < u8.length; i++) { -if (u8[i] > 0) out[i] = Math.expm1((u8[i] / 255) * denom); -} -} -return out; -} -function lodCopyGrid(f32) { -return f32.slice ? f32.slice() : new Float32Array(f32); -} -function lodWriteGridTexture(gl, tex, f32, w, h, maxVal) { -const data = new Uint8Array(f32.length); -const denom = Math.log1p(Math.max(0, maxVal || 0)); -if (denom > 0) { -for (let i = 0; i < f32.length; i++) { -const c = f32[i]; -if (c > 0 && Number.isFinite(c)) { -data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); -} -} -} -gl.bindTexture(gl.TEXTURE_2D, tex); -const align = gl.getParameter(gl.UNPACK_ALIGNMENT); -gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); -gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -} -function lodNormMax(g, nextMax) { -if (!Number.isFinite(nextMax) || nextMax <= 0) { -g.densityNormMax = 0; -return 0; -} -const prev = Number.isFinite(g.densityNormMax) && g.densityNormMax > 0 -? g.densityNormMax -: nextMax; -const norm = nextMax > prev -? prev * 0.3 + nextMax * 0.7 -: Math.max(nextMax, prev * 0.86); -g.densityNormMax = norm; -return norm; -} -function lodStartNormAnim(view, g, start, target) { -if (!g.density || !g.density.grid || !Number.isFinite(target) || target <= 0) { -g._densityNormAnim = null; -return; -} -const ratio = Math.abs(Math.log(Math.max(start, 1e-12) / Math.max(target, 1e-12))); -if (view._prefersReducedMotion() || ratio < 0.02) { -g._densityNormAnim = null; -g.density.normMax = target; -g.densityNormMax = target; -lodWriteGridTexture(view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target); -return; -} -g._densityNormAnim = { -start, -target, -startedAt: view._now(), -duration: target < start ? 420 : 260, -}; -} -function lodStepNorm(view, g) { -const anim = g._densityNormAnim; -const d = g.density; -if (!anim || !d || !d.grid || !d.tex) return; -const t = Math.min(1, Math.max(0, (view._now() - anim.startedAt) / anim.duration)); -const k = t * t * (3 - 2 * t); -const norm = anim.start + (anim.target - anim.start) * k; -const prev = d.normMax || 0; -const rel = Math.abs(norm - prev) / Math.max(Math.abs(norm), Math.abs(prev), 1); -if (rel > 0.004 || t >= 1) { -d.normMax = norm; -g.densityNormMax = norm; -lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm); -} -if (t < 1) { -view.draw(); -return; -} -d.normMax = anim.target; -g.densityNormMax = anim.target; -g._densityNormAnim = null; -} -function lodDensityArea(d) { -return Math.abs((d.xRange[1] - d.xRange[0]) * (d.yRange[1] - d.yRange[0])); -} -function lodWindowArea(win) { -if (!win) return 0; -return Math.abs((win.x1 - win.x0) * (win.y1 - win.y0)); -} -function lodWindowCenterInside(win, view) { -if (!win || !view) return false; -const cx = (view.x0 + view.x1) / 2; -const cy = (view.y0 + view.y1) / 2; -return ( -cx >= Math.min(win.x0, win.x1) && -cx <= Math.max(win.x0, win.x1) && -cy >= Math.min(win.y0, win.y1) && -cy <= Math.max(win.y0, win.y1) -); -} -function lodDensityForView(view, g) { -const cache = g.densityCache || (g.density ? [g.density] : []); -let best = null; -let broadest = null; -for (const d of cache) { -if (!d || !d.tex) continue; -if (!broadest || lodDensityArea(d) > lodDensityArea(broadest)) broadest = d; -if (!view._viewInsideRange(d.xRange, d.yRange)) continue; -if (!best || lodDensityArea(d) < lodDensityArea(best)) best = d; -} -return best || broadest || g.density; -} -function lodHoldPendingDrill(view, g, d) { -const pending = g._lodPendingView; -if (!d || !pending || g._drillDying) return false; -if (g._lodPendingSeq !== view.seq) return false; -if (g._lodPendingAt && view._now() - g._lodPendingAt > 1200) return false; -if (!lodWindowCenterInside(d.win, pending)) return false; -const drillArea = lodWindowArea(d.win); -const pendingArea = lodWindowArea(pending); -if (!Number.isFinite(drillArea) || !Number.isFinite(pendingArea) || drillArea <= 0) return false; -const baseVisible = Number.isFinite(d.visible) ? d.visible : d.n; -if (!Number.isFinite(baseVisible) || baseVisible <= 0) return false; -const estimatedVisible = baseVisible * Math.max(1, pendingArea / drillArea); -return estimatedVisible <= LOD_DIRECT_POINT_BUDGET * LOD_DRILL_EXIT_FACTOR; -} -function lodRememberDensity(view, g, d) { -if (!d || !d.tex) return; -d._stamp = ++view._densityStamp; -if (!g.densityCache) g.densityCache = []; -if (!g.densityCache.includes(d)) g.densityCache.push(d); -const maxCached = 8; -while (g.densityCache.length > maxCached) { -let drop = -1; -for (let i = 0; i < g.densityCache.length; i++) { -const cand = g.densityCache[i]; -if (cand === g.density) continue; -if (cand === g.prevDensity) continue; -if (cand === g._densitySwitchPrev) continue; -if (drop < 0) { drop = i; continue; } -const dropArea = lodDensityArea(g.densityCache[drop]); -const candArea = lodDensityArea(cand); -if (candArea < dropArea || (candArea === dropArea && cand._stamp < g.densityCache[drop]._stamp)) { -drop = i; -} -} -if (drop < 0) break; -const old = g.densityCache.splice(drop, 1)[0]; -if (old !== g.density && old !== g.prevDensity && old !== g._densitySwitchPrev) { -view.gl.deleteTexture(old.tex); -} -} -} -function lodApplyDrill(view, g, upd, buffers) { -const gl = view.gl; -const fresh = !g.drill; -let d = g.drill; -if (!d) { -d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; -} -d.xAxis = g.xAxis; -d.yAxis = g.yAxis; -gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf); -gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.x.buf]), gl.STATIC_DRAW); -gl.bindBuffer(gl.ARRAY_BUFFER, d.yBuf); -gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.y.buf]), gl.STATIC_DRAW); -d.xMeta = { offset: upd.x.offset, scale: upd.x.scale }; -d.yMeta = { offset: upd.y.offset, scale: upd.y.scale }; -d.win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1] }; -d.n = Math.min(upd.x.len, upd.y.len); -d.visible = upd.visible ?? d.n; -d.seq = upd.drill_seq; -d.selActive = false; -view._hoverId = -1; -view._lastRow = null; -d.colorMode = 0; -d.color = parseColor(view.root, upd.color && upd.color.color, [0.3, 0.47, 0.66, 1]); -if (upd.color && upd.color.buf !== undefined) { -d.colorMode = upd.color.mode === "continuous" ? 1 : 2; -if (!d.cBuf) d.cBuf = gl.createBuffer(); -const colorValues = upd.color.dtype === "u8" -? view._asU8(buffers[upd.color.buf]) -: view._asF32(buffers[upd.color.buf]); -d.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, d.cBuf); -gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); -d.lut = upd.color.mode === "continuous" -? view._lut(upd.color.colormap) -: view._paletteLut(upd.color.palette); -} -d.sizeMode = 0; -d.size = (upd.size && upd.size.size) || 4.0; -d.sizeRange = [2, 18]; -if (upd.size && upd.size.mode === "continuous") { -d.sizeMode = 1; -if (!d.sBuf) d.sBuf = gl.createBuffer(); -gl.bindBuffer(gl.ARRAY_BUFFER, d.sBuf); -gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.size.buf]), gl.STATIC_DRAW); -d.sizeRange = upd.size.range_px; -} -if (upd.density_val && upd.density_val.buf !== undefined) { -if (!d.dBuf) d.dBuf = gl.createBuffer(); -gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); -gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.density_val.buf]), gl.STATIC_DRAW); -d.dlut = view._lut(upd.density_colormap || "viridis"); -const first = d.lodBlend === undefined; -d.lodBlend = Math.min(1, upd.lod_blend ?? 0); -if (first) d.lodBlendShown = d.lodBlend; -} else { -d.lodBlend = 0; -} -if (fresh) { -g._drillFadeStart = view._now(); -g._drillWasInside = false; -g._drillShownAlpha = 0; -g._drillExitFadeStart = null; -g._drillDying = false; -g._drillDiedInsideWin = false; -return; -} -if (g._drillDying || g._drillExitFadeStart != null) { -lodEnterDrillContinuous(view, g); -} -g._drillDying = false; -g._drillDiedInsideWin = false; -} -function lodDropDrill(view, g) { -const d = g.drill; -if (!d) return; -const gl = view.gl; -view._deleteVaos(d); -for (const b of [d.xBuf, d.yBuf, d.cBuf, d.sBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); -g.drill = null; -g._drillFadeStart = null; -g._drillExitFadeStart = null; -g._drillWasInside = false; -g._drillShownAlpha = null; -g._drillDying = false; -g._drillDiedInsideWin = false; -view._hoverId = -1; -view._lastRow = null; -} -function lodMarkDrillDying(view, g) { -if (!g.drill) return; -g._drillDying = true; -g._drillDiedInsideWin = view._viewInside(g.drill.win); -lodBeginDrillExitContinuous(view, g); -} -function lodDrillExitFade(view, g) { -if (g._drillExitFadeStart === undefined || g._drillExitFadeStart === null) { -g._drillExitFadeStart = view._now(); -} -const fade = lodFade(view, g._drillExitFadeStart, LOD_EXIT_FADE_MS); -if (fade >= 1) g._drillExitFadeStart = null; -return fade; -} -const LOD_ENTRY_FADE_MS = 140; -const LOD_EXIT_FADE_MS = 120; -function lodFadeInvert(alpha) { -const a = Math.min(1, Math.max(0, alpha)); -return 0.5 - Math.sin(Math.asin(1 - 2 * a) / 3); -} -function lodDrillShownAlpha(view, g) { -if (g._drillExitFadeStart != null) { -return 1 - lodFade(view, g._drillExitFadeStart, LOD_EXIT_FADE_MS); -} -if (g._drillFadeStart != null) { -return lodFade(view, g._drillFadeStart, LOD_ENTRY_FADE_MS); -} -if (g._drillShownAlpha != null) return g._drillShownAlpha; -return g._drillWasInside ? 1 : 0; -} -function lodEnterDrillContinuous(view, g) { -const alpha = lodDrillShownAlpha(view, g); -g._drillShownAlpha = alpha; -g._drillExitFadeStart = null; -g._drillFadeStart = -alpha >= 1 ? null : view._now() - LOD_ENTRY_FADE_MS * lodFadeInvert(alpha); -} -function lodBeginDrillExitContinuous(view, g) { -if (g._drillExitFadeStart != null) return; -const alpha = lodDrillShownAlpha(view, g); -g._drillShownAlpha = alpha; -g._drillFadeStart = null; -g._drillExitFadeStart = view._now() - LOD_EXIT_FADE_MS * lodFadeInvert(1 - alpha); -} -function lodApplyDensityUpdate(view, g, upd, buffers) { -lodMarkDrillDying(view, g); -const d = upd.density; -const grid = d.enc === "log-u8" -? lodDecodeLogU8(buffers[d.buf], d.max) -: lodCopyGrid(view._asF32(buffers[d.buf])); -const normStart = lodNormMax(g, d.max); -const normMax = view._prefersReducedMotion() ? d.max : normStart; -g.densityNormMax = normMax; -g.prevDensity = g.density; -g._densityFadeStart = view._now(); -g.density = { -w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, -color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, -xRange: d.x_range, yRange: d.y_range, -grid, -tex: view._uploadGrid(grid, d.w, d.h, normMax), -lut: g.density.lut, -}; -if (Object.prototype.hasOwnProperty.call(d, "sample")) { -view._applyDensitySample(g, d.sample, buffers); -} -lodStartNormAnim(view, g, normMax, d.max); -lodRememberDensity(view, g, g.density); -} -function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { -if (density !== g._shownDensity) { -if (density === g._densitySwitchPrev && g._densitySwitchFadeStart != null) { -const f = lodFade(view, g._densitySwitchFadeStart, 140); -g._densitySwitchFadeStart = view._now() - 140 * lodFadeInvert(1 - f); -} else { -g._densitySwitchFadeStart = view._now(); -} -g._densitySwitchPrev = g._shownDensity; -g._shownDensity = density; -} -const prev = g._densitySwitchPrev; -const fade = prev && prev.tex ? lodFade(view, g._densitySwitchFadeStart, 140) : 1; -if (fade < 1) { -view._drawDensity(g, prev, (1 - fade) * opacityScale); -view._drawDensity(g, density, fade * opacityScale); -view.draw(); -return; -} -if (fade >= 1) { -if (g.prevDensity === g._densitySwitchPrev) g.prevDensity = null; -g._densitySwitchPrev = null; -g._densitySwitchFadeStart = null; -if (density === g.density) g._densityFadeStart = null; -} -view._drawDensity(g, density, opacityScale); -} -function lodDrawDensityTier(view, g, x0, x1, y0, y1) { -lodStepNorm(view, g); -const d = g.drill; -if (d && g._drillDying && !g._drillDiedInsideWin && view._viewInside(d.win)) { -g._drillDying = false; -lodEnterDrillContinuous(view, g); -g._drillWasInside = true; -} -const inside = d && !g._drillDying && view._viewInside(d.win); -const density = lodDensityForView(view, g); -if (inside) { -if (!g._drillWasInside || g._drillExitFadeStart != null) lodEnterDrillContinuous(view, g); -g._drillWasInside = true; -g._drillExitFadeStart = null; -const fade = lodFade(view, g._drillFadeStart); -g._drillShownAlpha = fade; -g._shownDensity = fade < 1 ? density : null; -g._densitySwitchPrev = null; -g._densitySwitchFadeStart = null; -if (fade < 1 && density && density.tex) { -view._drawDensity(g, density, 1 - fade); -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis), -fade -); -view.draw(); -} else { -g._drillFadeStart = null; -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis) -); -} -} else if (density && density.tex) { -if (lodHoldPendingDrill(view, g, d)) { -lodEnterDrillContinuous(view, g); -const fade = lodFade(view, g._drillFadeStart); -g._drillShownAlpha = fade; -if (fade < 1) { -view._drawDensity(g, density, 1 - fade); -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis), -fade -); -view.draw(); -} else { -g._drillFadeStart = null; -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis) -); -} -if (view._viewAnim) view.draw(); -return; -} -const exitingDrill = d && g._drillWasInside; -if (exitingDrill) lodBeginDrillExitContinuous(view, g); -const exitFade = exitingDrill ? lodDrillExitFade(view, g) : 1; -if (d) g._drillShownAlpha = exitingDrill && exitFade < 1 ? 1 - exitFade : 0; -if (exitingDrill && exitFade < 1) { -lodDrawDensityWithFade(view, g, density, exitFade); -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis), -1 - exitFade -); -view.draw(); -} else { -if (g._drillDying) lodDropDrill(view, g); -else if (exitingDrill) g._drillWasInside = false; -lodDrawDensityWithFade(view, g, density); -view._drawDensitySample(g, x0, x1, y0, y1); -} -} else if (d) { -view._drawPoints( -d, -view._map(d.xMeta, x0, x1, d.xAxis), -view._map(d.yMeta, y0, y1, d.yAxis) -); -} -} -const FC_REBIN_WORKER_SRC = ` -const DATA = new Map(); -self.onmessage = (e) => { - const m = e.data; - if (m.type === "init") { - DATA.set(m.trace, { x: new Float64Array(m.x), y: new Float64Array(m.y) }); - return; - } - const d = DATA.get(m.trace); - if (!d) return; - const w = m.w, h = m.h; - const grid = new Float32Array(w * h); - const sx = w / ((m.x1 - m.x0) || 1); - const sy = h / ((m.y1 - m.y0) || 1); - let max = 0; - const X = d.x, Y = d.y, n = X.length; - for (let i = 0; i < n; i++) { - const cx = (X[i] - m.x0) * sx; - const cy = (Y[i] - m.y0) * sy; - if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; - const v = ++grid[(cy | 0) * w + (cx | 0)]; - if (v > max) max = v; - } - self.postMessage( - { type: "grid", seq: m.seq, trace: m.trace, w, h, max, - x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer }, - [grid.buffer] - ); -}; -`; -function fcCreateRebinWorker() { -try { -const url = URL.createObjectURL( -new Blob([FC_REBIN_WORKER_SRC], { type: "application/javascript" }) -); -const worker = new Worker(url); -worker._fcUrl = url; -return worker; -} catch (e) { -return null; -} -} -const MARGIN = { l: 62, r: 14, t: 10, b: 42 }; -const COLORBAR_THICKNESS = 18; -const COLORBAR_GAP = 24; -let FC_A11Y_ID = 0; -const FC_SR_ONLY_STYLE = -"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;" + -"clip:rect(0,0,0,0);white-space:nowrap;border:0;"; -const UNITLESS_STYLE_PROPS = new Set([ -"animation-iteration-count", -"aspect-ratio", -"border-image-outset", -"border-image-slice", -"border-image-width", -"column-count", -"flex", -"flex-grow", -"flex-shrink", -"font-weight", -"line-height", -"opacity", -"order", -"orphans", -"tab-size", -"widows", -"z-index", -"zoom", -"fill-opacity", -"flood-opacity", -"stop-opacity", -"stroke-miterlimit", -"stroke-opacity", -]); -const FC_CONTEXT_GOVERNOR = { -views: new Set(), -seq: 1, -budget() { -const v = typeof window !== "undefined" ? window.XY_CONTEXT_BUDGET : null; -return Number.isFinite(v) && v >= 1 ? Math.floor(v) : 12; -}, -register(view) { -this.views.add(view); -}, -unregister(view) { -view._ctxPendingReservation = false; -this.views.delete(view); -}, -reserve(requester) { -const live = []; -let pending = 0; -for (const view of this.views) { -if (view !== requester && view.gl && !view._glLost && !view._destroyed) live.push(view); -if (view !== requester && view._ctxPendingReservation && !view._destroyed) pending += 1; -} -const needsReservation = !requester._ctxPendingReservation; -requester._ctxPendingReservation = true; -let over = live.length + pending + (needsReservation ? 1 : 0) - this.budget(); -if (over <= 0) return; -const candidates = live -.filter((view) => !view._ctxVisible) -.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); -for (const view of candidates) { -if (over <= 0) break; -if (view._releaseContext()) over -= 1; -} -if (over <= 0) return; -const visible = live -.filter((view) => view._ctxVisible) -.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); -for (const view of visible) { -if (over <= 0) break; -if (view._releaseContext()) over -= 1; -} -}, -acquired(requester) { -requester._ctxPendingReservation = false; -}, -cancel(requester) { -requester._ctxPendingReservation = false; -}, -}; -function fcInitiallyVisible(el) { -if (typeof window === "undefined" || !el.getBoundingClientRect) return true; -const rect = el.getBoundingClientRect(); -if (!rect.width && !rect.height) return false; -const vh = window.innerHeight || 0; -const vw = window.innerWidth || 0; -return ( -rect.bottom > -0.25 * vh && rect.top < 1.25 * vh && rect.right > -0.25 * vw && rect.left < 1.25 * vw -); -} -class ChartView { -constructor(el, spec, buffer, comm) { -if (spec.protocol !== PROTOCOL) { -el.textContent = -`xy: protocol mismatch (client speaks ${PROTOCOL}, kernel sent ${spec.protocol}). ` + -"Update the xy package and restart the kernel."; -throw new Error("protocol mismatch"); -} -this.spec = spec; -this.interaction = spec.interaction || {}; -this.markStyle = spec.mark_style || {}; -this.axes = this._normalizeAxes(spec); -this.comm = comm; -this.seq = 0; -this._densityStamp = 0; -this._viewRequestBurstStart = null; -this._viewAnim = null; -this._animRaf = null; -this._wheelZoomRaf = null; -this._pendingWheelZoom = null; -this._lastLabelDraw = null; -this._lutCache = new Map(); -this._listeners = []; -this._glPrograms = []; -this._progCache = new Map(); -this._bufSeq = 0; -this._destroyed = false; -this._hoverId = -1; -this._hoverTarget = null; -this._viewEventRaf = null; -this._linkedSource = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; -this.dragMode = "pan"; -this.fluid = spec.width === "100%"; -this.fluidH = spec.height === "100%"; -const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; -const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; -const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; -this.size = { -w: Math.max(this.fluid ? 120 : 48, cw), -h: Math.max(this.fluidH ? 120 : 48, ch), -}; -this._layout(); -this._buildDom(el); -this.theme = readTheme(this.root); -this._payload = buffer; -this._glLost = false; -this._ctxReleasedExt = null; -this._ctxReleases = 0; -this._ctxRecoveries = 0; -this._ctxVisible = fcInitiallyVisible(el); -FC_CONTEXT_GOVERNOR.register(this); -if (this._ctxVisible) this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; -this._contextLossCount = 0; -this._contextRestoreCount = 0; -this._contextRecoveryError = null; -this._initGl(buffer); -this._initA11y(); -this.root.dataset.fcContextState = "ready"; -this._initContextLossRecovery(); -this._armContextVisibilityWatch(); -this._initInteraction(); -this._buildModebar(this.root); -if ((this.fluid || this.fluidH) && typeof ResizeObserver !== "undefined") { -this._ro = new ResizeObserver((entries) => { -const r = entries[entries.length - 1].contentRect; -if (r.width || r.height) this._resize(r.width, r.height); -}); -this._ro.observe(this.root); -} -this._armVisibilityResizeWatch(); -this._armDprWatch(); -this.view0 = { -x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], -y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], -}; -this.view = { ...this.view0 }; -this._initLinkedCharts(); -this._themeWatch = window.matchMedia("(prefers-color-scheme: dark)"); -this._onScheme = () => this.refreshTheme(); -this._themeWatch.addEventListener?.("change", this._onScheme); -this._unsubscribeComm = comm ? comm.onMessage((msg, buffers) => this._onKernelMsg(msg, buffers)) : null; -this.draw(); -} -_layout() { -const compact = this.size.w < 520; -const pad = Array.isArray(this.spec.padding) ? this.spec.padding : null; -const marginLeft = pad ? pad[3] : compact ? 46 : MARGIN.l; -const colorbar = this.spec.colorbar; -const verticalColorbar = colorbar && colorbar.orientation !== "horizontal"; -const horizontalColorbar = colorbar && colorbar.orientation === "horizontal"; -const colorbarRightRoom = verticalColorbar ? 86 + (colorbar.label ? 18 : 0) : 0; -const colorbarBottomRoom = horizontalColorbar ? 38 + (colorbar.label ? 16 : 0) : 0; -const marginRight = (pad ? pad[1] : compact ? 8 : MARGIN.r) + colorbarRightRoom; -const marginTop = pad ? pad[0] : compact ? 6 : MARGIN.t; -const marginBottom = (pad ? pad[2] : compact ? 36 : MARGIN.b) + colorbarBottomRoom; -const topAxisRoom = this._axis("x").side === "top" ? (compact ? 26 : 32) : 0; -const top = marginTop + (this.spec.title ? (compact ? 26 : 30) : 0) + topAxisRoom; -const extraRightAxes = Object.values(this.axes || {}).filter((axis) => -axis && axis.id !== "y" && String(axis.id || "").startsWith("y") && axis.side === "right"); -const right = marginRight + (extraRightAxes.length ? (compact ? 42 : 54) : 0); -this.plot = { -x: marginLeft, -y: top, -w: Math.max(40, this.size.w - marginLeft - right), -h: Math.max(40, this.size.h - top - marginBottom), -}; -} -_normalizeAxes(spec) { -const axes = { ...(spec.axes || {}) }; -if (spec.x_axis) axes.x = spec.x_axis; -if (spec.y_axis) axes.y = spec.y_axis; -for (const [id, axis] of Object.entries(axes)) { -if (axis && typeof axis === "object" && !axis.id) axis.id = id; -} -return axes; -} -_axis(axisId) { -const id = axisId || "x"; -return this.axes[id] || (String(id).startsWith("y") ? this.axes.y : this.axes.x) || {}; -} -_axisDim(axisId) { -return String(axisId || "x").startsWith("y") ? "y" : "x"; -} -_axisMode(axisId) { -return this._axis(axisId).scale === "log" ? 1 : 0; -} -_axisCoord(axis, value) { -const v = Number(value); -if (!Number.isFinite(v)) return NaN; -if (axis && axis.scale === "log") return v > 0 ? Math.log10(v) : NaN; -return v; -} -_axisValue(axis, coord) { -if (axis && axis.scale === "log") return Math.pow(10, coord); -return coord; -} -_axisRange(axisId, view = this.view) { -if (axisId === "x") return [view.x0, view.x1]; -if (axisId === "y") return [view.y0, view.y1]; -const axis = this._axis(axisId); -const r = axis.range || [0, 1]; -return [Number(r[0]), Number(r[1])]; -} -_axisTicks(axisId, target) { -const axis = this._axis(axisId); -const [lo, hi] = this._axisRange(axisId); -if (Array.isArray(axis.tick_values)) { -const ticks = axis.tick_values.map(Number).filter((v) => Number.isFinite(v) && v >= lo && v <= hi); -return { ticks, labels: ticks, step: ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1 }; -} -if (axis.kind === "time") return timeTicks(lo, hi, target); -if (axis.kind === "category") return categoryTicks(lo, hi, axis.categories || [], target); -if (axis.scale === "log") return logTicks(lo, hi, target); -return linearTicks(lo, hi, target); -} -_axisTickText(axis, value, step) { -if (Array.isArray(axis.tick_values) && Array.isArray(axis.tick_labels)) { -const index = axis.tick_values.findIndex((candidate) => Number(candidate) === Number(value)); -if (index >= 0 && index < axis.tick_labels.length) return String(axis.tick_labels[index]); -} -return fmtAxis(axis, value, step); -} -_axisTickTarget(axisId, fallback) { -const axis = this._axis(axisId); -const requested = Number(axis && axis.tick_count); -if (Number.isFinite(requested) && requested > 0) { -return Math.max(1, Math.min(200, requested)); -} -return fallback; -} -_dataPx(axisId, value) { -const dim = this._axisDim(axisId); -const axis = this._axis(axisId); -const [lo, hi] = this._axisRange(axisId); -const c0 = this._axisCoord(axis, lo); -const c1 = this._axisCoord(axis, hi); -const c = this._axisCoord(axis, value); -if (![c0, c1, c].every(Number.isFinite) || c1 === c0) return NaN; -if (dim === "x") return this.plot.x + ((c - c0) / (c1 - c0)) * this.plot.w; -return this.plot.y + (1 - (c - c0) / (c1 - c0)) * this.plot.h; -} -_listen(target, type, handler, options) { -target.addEventListener(type, handler, options); -this._listeners.push({ target, type, handler, options }); -return handler; -} -_interactionFlag(name, fallback = false) { -const value = this.interaction && this.interaction[name]; -return value === undefined ? fallback : value === true; -} -_eventView(source = "view") { -return { -x0: this.view.x0, -x1: this.view.x1, -y0: this.view.y0, -y1: this.view.y1, -source, -}; -} -_dispatchChartEvent(name, detail) { -if (!this.root || typeof CustomEvent !== "function") return; -this.root.dispatchEvent(new CustomEvent(`xy:${name}`, { -detail, -bubbles: true, -composed: true, -})); -} -_emitViewChange(source = "view", opts = {}) { -const shouldDispatch = this._interactionFlag("view_change") || this._linkChannel; -if (!shouldDispatch || this._destroyed) return; -const broadcast = opts.broadcast !== false; -this._pendingViewEvent = { source, broadcast }; -if (this._viewEventRaf) return; -this._viewEventRaf = requestAnimationFrame(() => { -this._viewEventRaf = null; -const pending = this._pendingViewEvent || { source, broadcast }; -this._pendingViewEvent = null; -const detail = this._eventView(pending.source); -if (this._interactionFlag("view_change")) { -this._dispatchChartEvent("view_change", detail); -} -if (this.comm && this._interactionFlag("view_change")) { -this.comm.send({ type: "view_change", ...detail }); -} -if (pending.broadcast) this._broadcastLinkedView(detail); -}); -} -_initLinkedCharts() { -const group = this.interaction && this.interaction.link_group; -if (!group || typeof BroadcastChannel !== "function") return; -this._linkAxes = Array.isArray(this.interaction.link_axes) -? this.interaction.link_axes.filter((axis) => axis === "x" || axis === "y") -: ["x", "y"]; -if (!this._linkAxes.length) this._linkAxes = ["x", "y"]; -this._linkChannel = new BroadcastChannel(`xy:${group}`); -this._linkChannel.onmessage = (event) => { -const msg = event.data || {}; -if (!msg.view || msg.source === this._linkedSource) return; -const next = { ...this.view }; -if (this._linkAxes.includes("x")) { -next.x0 = Number(msg.view.x0); -next.x1 = Number(msg.view.x1); -} -if (this._linkAxes.includes("y")) { -next.y0 = Number(msg.view.y0); -next.y1 = Number(msg.view.y1); -} -if (![next.x0, next.x1, next.y0, next.y1].every(Number.isFinite)) return; -this._setView(next, { animate: false, source: "linked", broadcast: false }); -}; -} -_broadcastLinkedView(detail) { -if (!this._linkChannel) return; -this._linkChannel.postMessage({ source: this._linkedSource, view: detail }); -} -_applyClass(el, className) { -if (typeof className !== "string") return; -for (const token of className.split(/\s+/).filter(Boolean)) { -try { el.classList.add(token); } catch (_) { } -} -} -_stylePropertyName(key) { -if (key.startsWith("--")) return key; -return key.replace(/_/g, "-").replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); -} -_stylePropertyValue(property, value) { -if (typeof value !== "number") return String(value); -if (!Number.isFinite(value)) return null; -if (property.startsWith("--") || UNITLESS_STYLE_PROPS.has(property)) return String(value); -return `${value}px`; -} -_applyStyle(el, style) { -if (!style || typeof style !== "object" || Array.isArray(style)) return; -for (const [key, value] of Object.entries(style)) { -if (typeof key !== "string") continue; -if (typeof value !== "string" && typeof value !== "number") continue; -const property = this._stylePropertyName(key); -const cssValue = this._stylePropertyValue(property, value); -if (cssValue != null) el.style.setProperty(property, cssValue); -} -} -_applySlot(el, slot) { -if (el && el.dataset) el.dataset.fcSlot = slot; -const dom = this.spec.dom; -if (!dom || typeof dom !== "object") return; -if (slot === "root") this._applyClass(el, dom.class_name); -if (dom.class_names && typeof dom.class_names === "object") { -this._applyClass(el, dom.class_names[slot]); -} -if (slot === "root") this._applyStyle(el, dom.style); -if (dom.styles && typeof dom.styles === "object") { -this._applyStyle(el, dom.styles[slot]); -} -} -_slotStyleValue(slot, property) { -const styles = this.spec.dom?.styles; -const style = styles && typeof styles === "object" ? styles[slot] : null; -if (!style || typeof style !== "object" || Array.isArray(style)) return null; -const want = this._stylePropertyName(property); -for (const key of Object.keys(style)) { -if (this._stylePropertyName(key) === want) return style[key]; -} -return null; -} -_syncContainerSize() { -if (this._destroyed || !(this.fluid || this.fluidH) || !this.root) return; -const rect = this.root.getBoundingClientRect(); -if (rect.width || rect.height) this._resize(rect.width, rect.height); -} -_armVisibilityResizeWatch() { -if (!(this.fluid || this.fluidH)) return; -const syncSoon = () => { -if (this._destroyed) return; -requestAnimationFrame(() => this._syncContainerSize()); -}; -this._listen(window, "resize", syncSoon); -this._listen(window, "pageshow", syncSoon); -this._listen(document, "visibilitychange", syncSoon); -if (typeof IntersectionObserver !== "undefined") { -this._io = new IntersectionObserver((entries) => { -if (entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)) { -syncSoon(); -} -}); -this._io.observe(this.root); -} -} -_markStateValue(state, property, fallback = null) { -const styles = this.markStyle && typeof this.markStyle === "object" ? this.markStyle[state] : null; -if (!styles || typeof styles !== "object" || Array.isArray(styles)) return fallback; -if (Object.prototype.hasOwnProperty.call(styles, property)) return styles[property]; -return fallback; -} -_markStateNumber(state, property, fallback) { -const value = this._markStateValue(state, property, fallback); -if (typeof value !== "number" || !Number.isFinite(value)) return fallback; -return value; -} -_markStatePaint(state, property, fallback) { -const value = this._markStateValue(state, property, fallback); -return typeof value === "string" ? value : fallback; -} -_armDprWatch() { -if (typeof window.matchMedia !== "function") return; -this._dprMq?.removeEventListener?.("change", this._onDprChange); -const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); -this._onDprChange = () => { -if (this._destroyed) return; -this._resize(this.size.w, this.size.h); -this._armDprWatch(); -}; -mq.addEventListener?.("change", this._onDprChange, { once: true }); -this._dprMq = mq; -} -_initContextLossRecovery() { -this._listen(this.canvas, "webglcontextlost", (e) => { -e.preventDefault(); -if (this._destroyed) return; -const governedRelease = this.canvas.dataset.fcCtx === "released"; -if (this._glLost && !governedRelease) return; -this._glLost = true; -if (!governedRelease) this.canvas.dataset.fcCtx = "lost"; -this._contextLossCount += 1; -this._contextRecoveryError = null; -this.root.dataset.fcContextState = "lost"; -this.seq += 1; -if (this._raf) cancelAnimationFrame(this._raf); -this._raf = null; -if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); -this._wheelZoomRaf = null; -this._pendingWheelZoom = null; -this._cancelViewAnimation(); -clearTimeout(this._viewTimer); -this._viewTimer = null; -clearTimeout(this._rebinTimer); -this._rebinTimer = null; -this._viewRequestBurstStart = null; -this._dispatchChartEvent("context_lost", { -loss_count: this._contextLossCount, -}); -}); -this._listen(this.canvas, "webglcontextrestored", () => { -if (this._destroyed || this._contextRecoveryError) return; -this._lutCache.clear(); -this.pickFbo = null; -this.pickTex = null; -try { -this._initGl(this._payload); -} catch (err) { -this._glLost = true; -this._contextRecoveryError = err; -this.root.dataset.fcContextState = "failed"; -try { this._destroyGlResources(); } catch (_cleanupErr) {} -this.gl = null; -this._dispatchChartEvent("context_restore_failed", { -loss_count: this._contextLossCount, -message: err instanceof Error ? err.message : String(err), -}); -this.root.textContent = "xy: WebGL2 context could not be restored."; -return; -} -this._glLost = false; -this._contextRestoreCount += 1; -this._contextRecoveryError = null; -this.root.dataset.fcContextState = "ready"; -this._scheduleViewRequest(this.view, { delay: 0 }); -this.draw(); -this._dropContextSnapshot(); -this._dispatchChartEvent("context_restored", { -loss_count: this._contextLossCount, -restore_count: this._contextRestoreCount, -}); -}); -} -_releaseContext() { -if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; -const ext = this.gl.getExtension("WEBGL_lose_context"); -if (!ext) return false; -this._snapshotBeforeRelease(); -this._ctxReleasedExt = ext; -this._ctxReleases += 1; -this._glLost = true; -this.canvas.dataset.fcCtx = "released"; -if (this._raf) cancelAnimationFrame(this._raf); -this._raf = null; -ext.loseContext(); -return true; -} -_snapshotBeforeRelease() { -try { -if (this._raf) cancelAnimationFrame(this._raf); -this._raf = null; -this._rafKeepPick = true; -this._drawNow(); -let snap = this._ctxSnapshot; -if (!snap) { -snap = this._ctxSnapshot = document.createElement("canvas"); -snap.dataset.fcCtxSnapshot = ""; -} -snap.width = this.canvas.width; -snap.height = this.canvas.height; -snap.style.cssText = this.canvas.style.cssText; -snap.style.pointerEvents = "none"; -snap.getContext("2d").drawImage(this.canvas, 0, 0); -this.canvas.before(snap); -this.canvas.style.visibility = "hidden"; -} catch (_err) { -this._dropContextSnapshot(); -} -} -_dropContextSnapshot() { -this.canvas.style.visibility = ""; -if (this._ctxSnapshot) this._ctxSnapshot.remove(); -this._ctxSnapshot = null; -} -_recoverContext() { -if (this._destroyed || !this._glLost) return; -this._ctxRecoveries += 1; -if (this._ctxReleasedExt) { -const ext = this._ctxReleasedExt; -this._ctxReleasedExt = null; -try { -FC_CONTEXT_GOVERNOR.reserve(this); -ext.restoreContext(); -return; -} catch (_err) { -FC_CONTEXT_GOVERNOR.cancel(this); -} -} -this._rebuildEvictedContext(); -} -_rebuildEvictedContext() { -const fresh = this.canvas.cloneNode(false); -for (const record of this._listeners) { -if (record.target === this.canvas) { -this.canvas.removeEventListener(record.type, record.handler, record.options); -fresh.addEventListener(record.type, record.handler, record.options); -record.target = fresh; -} -} -this.canvas.replaceWith(fresh); -this.canvas = fresh; -this._glLost = false; -this._lutCache.clear(); -this.pickFbo = null; -this.pickTex = null; -try { -this._initGl(this._payload); -} catch (_err) { -this._glLost = true; -this.canvas.dataset.fcCtx = "lost"; -return; -} -this._scheduleViewRequest(this.view, { delay: 0 }); -this.draw(); -this._dropContextSnapshot(); -} -_armContextVisibilityWatch() { -this._listen(this.root, "pointerenter", () => { -if (this._glLost && !this._destroyed) this._recoverContext(); -}); -if (typeof IntersectionObserver === "undefined") { -this._ctxVisible = true; -return; -} -this._ctxIo = new IntersectionObserver( -(entries) => { -const entry = entries[entries.length - 1]; -this._ctxVisible = entry.isIntersecting || entry.intersectionRatio > 0; -if (this._ctxVisible) { -this._ctxSeenSeq = FC_CONTEXT_GOVERNOR.seq++; -if (this._glLost && !this._destroyed) this._recoverContext(); -} -}, -{ rootMargin: "25% 0px 25% 0px" }, -); -this._ctxIo.observe(this.root); -} -_resize(cssW, cssH) { -const w = this.fluid && cssW ? Math.max(120, Math.round(cssW)) : this.size.w; -const h = this.fluidH && cssH ? Math.max(120, Math.round(cssH)) : this.size.h; -const dpr = window.devicePixelRatio || 1; -if (w === this.size.w && h === this.size.h && dpr === this.dpr) return; -this.dpr = dpr; -this.size.w = w; -this.size.h = h; -this._layout(); -const p = this.plot; -this.canvas.style.width = p.w + "px"; -this.canvas.style.height = p.h + "px"; -this.canvas.width = p.w * this.dpr; -this.canvas.height = p.h * this.dpr; -this.chrome.style.width = this.size.w + "px"; -this.chrome.style.height = this.size.h + "px"; -this.chrome.width = this.size.w * this.dpr; -this.chrome.height = this.size.h * this.dpr; -if (this._legends && this._legends.length && this._slotStyleValue("legend", "max-height") == null) { -for (const lg of this._legends) lg.style.maxHeight = p.h - 12 + "px"; -} -this._positionReductionBadges(); -this._positionColorbar(); -this._fitModebar(); -this._pickDirty = true; -this.draw(); -this._scheduleViewRequest(); -} -_buildDom(el) { -const s = this.spec; -const root = document.createElement("div"); -root.className = "xy"; -root.style.cssText = -`position:relative;width:${this.fluid ? "100%" : this.size.w + "px"};` + -`height:${this.fluidH ? "100%" : this.size.h + "px"};` + -(this.fluidH ? "min-height:120px;" : "") + -"font:12px system-ui,sans-serif;user-select:none;"; -this._applySlot(root, "root"); -el.appendChild(root); -this.root = root; -ensureChromeStylesheet(root); -let a11yId; -do { -a11yId = `xy-a11y-${++FC_A11Y_ID}`; -} while ( -document.getElementById(`${a11yId}-summary`) || document.getElementById(`${a11yId}-live`) -); -root.setAttribute("role", "region"); -root.setAttribute("aria-label", s.title ? `Chart: ${s.title}` : "Interactive chart"); -this.a11ySummary = document.createElement("div"); -this.a11ySummary.id = `${a11yId}-summary`; -this.a11ySummary.style.cssText = FC_SR_ONLY_STYLE; -root.setAttribute("aria-describedby", this.a11ySummary.id); -root.appendChild(this.a11ySummary); -this.a11yLive = document.createElement("div"); -this.a11yLive.id = `${a11yId}-live`; -this.a11yLive.setAttribute("role", "status"); -this.a11yLive.setAttribute("aria-live", "polite"); -this.a11yLive.setAttribute("aria-atomic", "true"); -this.a11yLive.style.cssText = FC_SR_ONLY_STYLE; -root.appendChild(this.a11yLive); -if (s.title) { -const t = document.createElement("div"); -t.textContent = s.title; -t.style.cssText = "position:absolute;top:6px;left:0;right:0;"; -this._applySlot(t, "title"); -root.appendChild(t); -} -this.chrome = document.createElement("canvas"); -this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;"; -this._applySlot(this.chrome, "chrome"); -root.appendChild(this.chrome); -this.canvas = document.createElement("canvas"); -this.canvas.style.cssText = -`position:absolute;left:${this.plot.x}px;top:${this.plot.y}px;` + -`width:${this.plot.w}px;height:${this.plot.h}px;touch-action:none;`; -this._applySlot(this.canvas, "canvas"); -this.canvas.tabIndex = 0; -this.canvas.setAttribute("role", "img"); -this.canvas.setAttribute("aria-describedby", this.a11ySummary.id); -root.appendChild(this.canvas); -this.labels = document.createElement("div"); -this.labels.style.cssText = "position:absolute;inset:0;pointer-events:none;"; -this._applySlot(this.labels, "labels"); -root.appendChild(this.labels); -this.tooltip = document.createElement("div"); -this.tooltip.style.cssText = -"position:absolute;display:none;pointer-events:none;z-index:5;white-space:nowrap;"; -this._applySlot(this.tooltip, "tooltip"); -this.tooltip.setAttribute("aria-hidden", "true"); -root.appendChild(this.tooltip); -this._buildLegend(root); -this._buildColorbar(root); -this._buildReductionBadges(root); -} -_a11yAxisSummary(axisId, name) { -const axis = this._axis(axisId); -const range = axis.range || []; -if (range.length < 2) return null; -const label = axis.label ? `${name} axis (${axis.label})` : `${name} axis`; -return `${label} ranges from ${fmtValue(range[0], axis.kind)} to ${fmtValue(range[1], axis.kind)}.`; -} -_a11ySummaryText() { -const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; -const parts = [this.spec.title ? `${this.spec.title}.` : "Interactive chart."]; -parts.push(`${traces.length} data series.`); -const names = traces.map((trace) => trace && trace.name).filter(Boolean).slice(0, 6); -if (names.length) parts.push(`Series: ${names.join(", ")}.`); -const x = this._a11yAxisSummary("x", "X"); -const y = this._a11yAxisSummary("y", "Y"); -if (x) parts.push(x); -if (y) parts.push(y); -return parts.join(" "); -} -_initA11y() { -if (!this.a11ySummary || !this.canvas) return; -this.a11ySummary.textContent = this._a11ySummaryText(); -const instruction = this._pickable -? " Use Arrow keys to explore data points in series data order; Home and End jump to the first and last point; Escape closes the readout." -: ""; -this.canvas.setAttribute("aria-label", `Plot area.${instruction}`); -} -_compactInt(value) { -const n = Number(value); -if (!Number.isFinite(n)) return "0"; -return Math.round(n).toLocaleString(); -} -_positionReductionBadges() { -if (!this._badges) return; -const rightInset = this.size.w - (this.plot.x + this.plot.w); -const bottomInset = this.size.h - (this.plot.y + this.plot.h); -this._badges.style.right = `${rightInset + 6}px`; -this._badges.style.bottom = `${bottomInset + 6}px`; -} -_reductionBadgeItems() { -const items = []; -const traces = this.gpuTraces && this.gpuTraces.length -? this.gpuTraces -: (this.spec.traces || []); -for (const entry of traces) { -const t = entry.trace || entry; -if (t.tier !== "density" || !t.density) continue; -const sample = entry.sampleOverlay && entry.sampleOverlay.sample -? entry.sampleOverlay.sample -: t.density.sample; -if (sample && Number(sample.n) > 0) { -items.push(`sampled ${this._compactInt(sample.n)} of ${this._compactInt(sample.visible)}`); -} -if (entry._sampleRebinned) items.push("zoom re-binned from sample"); -if (t.density.channels_dropped) items.push("aggregated channels"); -} -return items; -} -_refreshReductionBadges() { -if (!this._badges) return; -const items = this._reductionBadgeItems(); -this._badges.textContent = ""; -this._badges.hidden = items.length === 0; -for (const item of items) { -const badge = document.createElement("div"); -badge.textContent = item; -this._applySlot(badge, "badge_item"); -this._badges.appendChild(badge); -} -this._positionReductionBadges(); -} -_buildReductionBadges(root) { -const items = this._reductionBadgeItems(); -const hasDensityTrace = (this.spec.traces || []).some((t) => t.tier === "density"); -if (!items.length && !hasDensityTrace) return; -const box = document.createElement("div"); -box.style.cssText = -"position:absolute;display:flex;flex-direction:column;align-items:flex-end;" + -"pointer-events:none;z-index:4;"; -this._applySlot(box, "badge"); -root.appendChild(box); -this._badges = box; -this._refreshReductionBadges(); -} -_buildLegend(root) { -const s = this.spec; -this._legends = []; -const items = []; -if (s.show_legend !== false) { -for (const t of s.traces) { -if (t.tier === "density") { -items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" }); -} else if (t.color && t.color.mode === "categorical") { -t.color.categories.forEach((cat, i) => -items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} })); -} else if (t.color && t.color.mode === "continuous") { -items.push({ swatch: "gradient", cmap: t.color.colormap, name: t.name || "value" }); -} else if (t.name) { -const c = (t.color && t.color.color) || (t.style && t.style.color); -const line = ["line", "segments", "step", "stairs", "errorbar"].includes(t.kind); -items.push({ swatch: c, name: t.name, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, line, style: t.style || {} }); -} -} -if (items.length) this._legendBox(root, items, s.legend || {}); -} -for (const extra of s.extra_legends || []) { -const mapped = (extra.items || []).map((it) => ({ -swatch: it.style && it.style.color, -name: it.name, -symbol: it.kind === "scatter" ? (it.style?.symbol || "circle") : null, -line: ["line", "segments", "step", "stairs", "errorbar"].includes(it.kind), -style: it.style || {}, -})); -if (mapped.length) this._legendBox(root, mapped, extra); -} -} -_legendBox(root, items, options) { -const lg = document.createElement("div"); -const loc = options.loc || "upper right"; -const ncols = Math.max(1, Number(options.ncols) || 1); -const rightInset = this.size.w - (this.plot.x + this.plot.w); -const horizontal = ncols > 1; -const h = loc.includes("left") ? "left" : loc.includes("right") ? "right" : "center"; -const v = loc.includes("upper") ? "upper" : loc.includes("lower") ? "lower" : "center"; -let xPos, yPos, tx = "0", ty = "0"; -if (h === "left") xPos = `left:${this.plot.x + 6}px;`; -else if (h === "right") xPos = `right:${rightInset + 6}px;`; -else { xPos = `left:${this.plot.x + this.plot.w / 2}px;`; tx = "-50%"; } -if (v === "upper") yPos = `top:${this.plot.y + 6}px;`; -else if (v === "lower") yPos = `bottom:${this.size.h - (this.plot.y + this.plot.h) + 6}px;`; -else { yPos = `top:${this.plot.y + this.plot.h / 2}px;`; ty = "-50%"; } -const transform = tx === "0" && ty === "0" ? "" : `transform:translate(${tx},${ty});`; -lg.style.cssText = `position:absolute;${xPos}${yPos}${transform}` + -`display:grid;grid-template-columns:repeat(${horizontal ? ncols : 1},max-content);` + -"overflow:auto;" + `max-height:${this.plot.h - 12}px;`; -this._applySlot(lg, "legend"); -if (options.title) { -const title = document.createElement("div"); -title.textContent = String(options.title); -title.style.fontWeight = "600"; -title.style.gridColumn = `1 / span ${horizontal ? ncols : 1}`; -lg.appendChild(title); -} -for (const it of items) { -const row = document.createElement("div"); -this._applySlot(row, "legend_item"); -const sw = document.createElement("span"); -sw.style.display = "inline-block"; -sw.style.verticalAlign = "-1px"; -let bg = it.swatch; -if (it.swatch === "gradient") { -const stops = colormapStops(it.cmap); -bg = `linear-gradient(90deg,${stops.map((c) => `rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`; -sw.style.background = bg; -} else if (it.symbol) { -const ns = "http://www.w3.org/2000/svg"; -const svg = document.createElementNS(ns, "svg"); -svg.setAttribute("viewBox", "0 0 18 14"); -svg.setAttribute("width", "18"); -svg.setAttribute("height", "14"); -const path = document.createElementNS(ns, "path"); -const paths = { -square: "M4.5 2.5h9v9h-9z", diamond: "M9 2l5 5-5 5-5-5z", -thin_diamond: "M9 2l3 5-3 5-3-5z", -triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z", -triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z", -plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8", -cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z", -x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z", -pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z", -hexagon: "M9 2L13.3 4.5v5L9 12l-4.3-2.5v-5z", -star: "M9 2l1.5 3.1 3.5.5-2.5 2.5.6 3.5L9 10l-3.1 1.6.6-3.5L4 5.6l3.5-.5z" -}; -const color = safeCssPaint(this.root, bg); -if (it.symbol === "circle" || it.symbol === "point" || it.symbol === "pixel") { -if (it.symbol === "pixel") path.setAttribute("d", "M8.5 6.5h1v1h-1z"); -else path.setAttribute("d", `M9 ${it.symbol === "point" ? 4.75 : 2.5}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 ${it.symbol === "point" ? 4.5 : 9}a${it.symbol === "point" ? 2.25 : 4.5} ${it.symbol === "point" ? 2.25 : 4.5} 0 1 0 0 -${it.symbol === "point" ? 4.5 : 9}`); -} else path.setAttribute("d", paths[it.symbol] || paths.square); -path.setAttribute("fill", it.symbol.endsWith("_line") ? "none" : color); -path.setAttribute("stroke", color); -path.setAttribute("stroke-width", String(it.style?.stroke_width || 1)); -svg.appendChild(path); -sw.appendChild(svg); -sw.style.width = "18px"; -sw.style.height = "14px"; -} else if (it.line) { -const ns = "http://www.w3.org/2000/svg"; -const svg = document.createElementNS(ns, "svg"); -svg.setAttribute("viewBox", "0 0 22 12"); -svg.setAttribute("width", "22"); -svg.setAttribute("height", "12"); -const ln = document.createElementNS(ns, "line"); -ln.setAttribute("x1", "1"); -ln.setAttribute("y1", "6"); -ln.setAttribute("x2", "21"); -ln.setAttribute("y2", "6"); -ln.setAttribute("stroke", safeCssPaint(this.root, bg)); -ln.setAttribute("stroke-width", String(it.style?.width ?? 1.5)); -if (it.style?.dash && it.style.dash.length) ln.setAttribute("stroke-dasharray", it.style.dash.join(" ")); -svg.appendChild(ln); -sw.appendChild(svg); -sw.style.width = "22px"; -sw.style.height = "12px"; -} else { -sw.style.background = safeCssPaint(this.root, bg); -} -this._applySlot(sw, "legend_swatch"); -row.appendChild(sw); -row.appendChild(document.createTextNode(it.name)); -lg.appendChild(row); -} -root.appendChild(lg); -this._legends.push(lg); -return lg; -} -_buildColorbar(root) { -const cb = this.spec.colorbar; -if (!cb) return; -const box = document.createElement("div"); -const horizontal = cb.orientation === "horizontal"; -box.style.cssText = "position:absolute;pointer-events:none;z-index:4;"; -this._applySlot(box, "colorbar"); -const bar = document.createElement("div"); -const levels = Math.max(0, Number(cb.levels) || 0); -let gradient; -if (levels > 0) { -const lut = buildLutData(cb.colormap || "viridis"); -const bands = []; -for (let index = 0; index < levels; index++) { -const sample = Math.min(255, Math.round(255 * (index + 0.5) / levels)); -const color = `rgb(${lut[sample * 4]},${lut[sample * 4 + 1]},${lut[sample * 4 + 2]})`; -bands.push(`${color} ${100 * index / levels}% ${100 * (index + 1) / levels}%`); -} -gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${bands.join(",")})`; -} else { -const stops = colormapStops(cb.colormap || "viridis"); -gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${stops.map((c) => -`rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`; -} -bar.style.cssText = horizontal -? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;` -: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`; -bar.style.setProperty("--xy-colorbar-gradient", gradient); -this._applySlot(bar, "colorbar_bar"); -box.appendChild(bar); -const domain = cb.domain || [0, 1]; -const lo = Number(domain[0]), hi = Number(domain[1]); -const span = hi - lo || 1; -const tickResult = linearTicks(lo, hi, 8); -const tickValues = Array.isArray(cb.ticks) ? cb.ticks : tickResult.ticks; -const tickStep = tickResult.step; -for (const raw of tickValues) { -const value = Number(raw); -if (!Number.isFinite(value) || value < Math.min(lo, hi) || value > Math.max(lo, hi)) continue; -const tick = document.createElement("span"); -tick.textContent = fmtLinear(value, tickStep); -const fraction = (value - lo) / span; -tick.style.cssText = horizontal -? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;` -: `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`; -this._applySlot(tick, "colorbar_tick"); -box.appendChild(tick); -} -if (cb.label) { -const label = document.createElement("span"); -label.textContent = String(cb.label); -label.style.cssText = horizontal -? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;` -: `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`; -this._applySlot(label, "colorbar_title"); -box.appendChild(label); -} -box.title = `${cb.label ? cb.label + ": " : ""}${domain[0]} – ${domain[1]}`; -root.appendChild(box); -this._colorbar = box; -this._colorbarHorizontal = horizontal; -this._positionColorbar(); -} -_positionColorbar() { -if (!this._colorbar) return; -const horizontal = this._colorbarHorizontal; -this._colorbar.style.left = (horizontal ? this.plot.x : this.plot.x + this.plot.w + COLORBAR_GAP) + "px"; -this._colorbar.style.top = (horizontal ? this.plot.y + this.plot.h + 8 : this.plot.y) + "px"; -this._colorbar.style.width = (horizontal ? this.plot.w : 66) + "px"; -this._colorbar.style.height = (horizontal ? 50 : Math.max(24, this.plot.h)) + "px"; -} -_initGl(buffer) { -const dpr = window.devicePixelRatio || 1; -this.dpr = dpr; -this.canvas.width = this.plot.w * dpr; -this.canvas.height = this.plot.h * dpr; -this.chrome.width = this.size.w * dpr; -this.chrome.height = this.size.h * dpr; -this.chrome.style.width = this.size.w + "px"; -this.chrome.style.height = this.size.h + "px"; -FC_CONTEXT_GOVERNOR.reserve(this); -const gl = this.canvas.getContext("webgl2", { -antialias: false, premultipliedAlpha: true, alpha: true, -}); -if (!gl) { -FC_CONTEXT_GOVERNOR.cancel(this); -this.root.textContent = "xy: WebGL2 unavailable in this browser."; -throw new Error("webgl2 unavailable"); -} -this.gl = gl; -FC_CONTEXT_GOVERNOR.acquired(this); -this.canvas.dataset.fcCtx = "live"; -gl.enable(gl.BLEND); -gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); -this._progCache = new Map(); -this._glPrograms = this._progCache; -this.quad = gl.createBuffer(); -this.quad._fcId = ++this._bufSeq; -gl.bindBuffer(gl.ARRAY_BUFFER, this.quad); -gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); -this.quadVao = gl.createVertexArray(); -gl.bindVertexArray(this.quadVao); -gl.enableVertexAttribArray(ATTR_SLOTS.a_corner); -gl.vertexAttribPointer(ATTR_SLOTS.a_corner, 2, gl.FLOAT, false, 0, 0); -gl.vertexAttribDivisor(ATTR_SLOTS.a_corner, 0); -gl.bindVertexArray(null); -this.gpuTraces = this.spec.traces.map((t) => this._buildTrace(buffer, t)); -this._pickable = this.gpuTraces.some((g) => markOf(g.trace.kind).pointPick && g.tier !== "density"); -if (this._pickable) this._initPickTarget(); -} -_prog(key, vs, fs) { -let p = this._progCache.get(key); -if (!p) { -p = makeProgram(this.gl, vs, fs); -this._progCache.set(key, p); -} -return p; -} -get pointProg() { return this._prog("point", POINT_VS, POINT_FS); } -get pointSimpleProg() { return this._prog("point-simple", POINT_SIMPLE_VS, POINT_SIMPLE_FS); } -get lineProg() { return this._prog("line", LINE_VS, LINE_FS); } -get segmentProg() { return this._prog("segment", SEGMENT_VS, SEGMENT_FS); } -get meshProg() { return this._prog("mesh", MESH_VS, MESH_FS); } -get areaProg() { return this._prog("area", AREA_VS, AREA_FS); } -get rectProg() { return this._prog("rect", RECT_VS, RECT_FS); } -get barProg() { return this._prog("bar", BAR_VS, RECT_FS); } -get pickProg() { return this._prog("pick", PICK_VS, PICK_FS); } -get densityProg() { return this._prog("density", GRID_VS, DENSITY_FS); } -get heatmapProg() { return this._prog("heatmap", GRID_VS, HEATMAP_FS); } -_lut(name) { -if (this._lutCache.has(name)) return this._lutCache.get(name); -const gl = this.gl; -const tex = gl.createTexture(); -gl.bindTexture(gl.TEXTURE_2D, tex); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, buildLutData(name)); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -this._lutCache.set(name, tex); -return tex; -} -_paletteLut(palette) { -const key = "pal:" + palette.join(","); -if (this._lutCache.has(key)) return this._lutCache.get(key); -const gl = this.gl; -const data = new Uint8Array(256 * 4); -for (let i = 0; i < 256; i++) { -const c = hexColor(palette[i % palette.length]); -data[i * 4] = c[0] * 255; -data[i * 4 + 1] = c[1] * 255; -data[i * 4 + 2] = c[2] * 255; -data[i * 4 + 3] = 255; -} -const tex = gl.createTexture(); -gl.bindTexture(gl.TEXTURE_2D, tex); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -this._lutCache.set(key, tex); -return tex; -} -_buildTrace(buffer, t) { -const gl = this.gl; -const g = { -trace: t, -tier: t.tier, -color: [0.3, 0.47, 0.66, 1], -xAxis: typeof t.x_axis === "string" ? t.x_axis : "x", -yAxis: typeof t.y_axis === "string" ? t.y_axis : "y", -}; -if (t.tier === "density") { -const d = t.density; -const meta = this.spec.columns[d.buf]; -const raw = this._columnView(buffer, meta); -const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw; -g.densityNormMax = d.max; -g.density = { -w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap, -color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null, -xRange: d.x_range, yRange: d.y_range, -grid: lodCopyGrid(grid), -tex: this._uploadGrid(grid, d.w, d.h, d.max), -lut: this._lut(d.colormap), -}; -g.sampleOverlay = this._buildDensitySample(t, d.sample, buffer); -g._shownDensity = g.density; -lodRememberDensity(this, g, g.density); -return g; -} -markOf(t.kind).build(this, g, t, buffer); -return g; -} -_buildXY(g, t, buffer) { -const x = this._columnView(buffer, this.spec.columns[t.x]); -const y = this._columnView(buffer, this.spec.columns[t.y]); -g.xMeta = { ...this.spec.columns[t.x] }; -g.yMeta = { ...this.spec.columns[t.y] }; -g.n = Math.min(x.length, y.length); -g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; -g.xBuf = this._upload(x); -g.yBuf = this._upload(y); -} -_buildScatterMark(g, t, buffer) { -this._buildXY(g, t, buffer); -g.colorMode = 0; -g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); -if (t.color && t.color.mode === "continuous") { -g.colorMode = 1; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._lut(t.color.colormap); -} else if (t.color && t.color.mode === "categorical") { -g.colorMode = 2; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._paletteLut(t.color.palette); -} -g.sizeMode = 0; -g.size = (t.size && t.size.size) || 4.0; -g.sizeRange = [2, 18]; -if (t.size && t.size.mode === "continuous") { -g.sizeMode = 1; -g.sBuf = this._upload(this._columnView(buffer, this.spec.columns[t.size.buf])); -g.sizeRange = t.size.range_px; -} -this._pointMarkStyle(g, t); -} -_pointMarkStyle(g, t) { -const s = t.style || {}; -g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; -g.pointStrokeWidth = Number(s.stroke_width) || 0; -g.pointStrokeFace = !s.stroke; -g.pointStroke = s.stroke -? parseColor(this.root, s.stroke, [g.color[0], g.color[1], g.color[2], 1]) -: null; -} -_sampleTraceSpec(parentTrace, sample) { -return { -id: parentTrace.id, -kind: "scatter", -name: parentTrace.name, -style: sample.style || parentTrace.style || {}, -tier: "sampled", -x: sample.x && sample.x.col, -y: sample.y && sample.y.col, -x_axis: parentTrace.x_axis, -y_axis: parentTrace.y_axis, -color: sample.color, -size: sample.size, -}; -} -_buildDensitySample(parentTrace, sample, buffer) { -if (!sample || !sample.x || !sample.y || sample.x.col === undefined || sample.y.col === undefined) { -return null; -} -const trace = this._sampleTraceSpec(parentTrace, sample); -const g = { -trace, -tier: "sampled", -xAxis: typeof parentTrace.x_axis === "string" ? parentTrace.x_axis : "x", -yAxis: typeof parentTrace.y_axis === "string" ? parentTrace.y_axis : "y", -}; -this._buildScatterMark(g, trace, buffer); -g.win = { -x0: sample.x_range[0], x1: sample.x_range[1], -y0: sample.y_range[0], y1: sample.y_range[1], -}; -g.sample = { n: sample.n, visible: sample.visible }; -return g; -} -_destroyDensitySample(g) { -const s = g && g.sampleOverlay; -if (!s || !this.gl) return; -for (const b of [s.xBuf, s.yBuf, s.cBuf, s.sBuf, s.selBuf, s.dBuf]) { -if (b) this.gl.deleteBuffer(b); -} -g.sampleOverlay = null; -} -_applyDensitySample(g, sample, buffers) { -this._destroyDensitySample(g); -if (!sample || !sample.x || !sample.y || sample.x.buf === undefined || sample.y.buf === undefined) { -this._refreshReductionBadges(); -return; -} -const gl = this.gl; -const trace = { -id: g.trace.id, -kind: "scatter", -name: g.trace.name, -style: sample.style || g.trace.style || {}, -tier: "sampled", -x_axis: g.trace.x_axis, -y_axis: g.trace.y_axis, -color: sample.color, -size: sample.size, -}; -const s = { -trace, -tier: "sampled", -xAxis: g.xAxis, -yAxis: g.yAxis, -xBuf: gl.createBuffer(), -yBuf: gl.createBuffer(), -xMeta: { offset: sample.x.offset, scale: sample.x.scale }, -yMeta: { offset: sample.y.offset, scale: sample.y.scale }, -n: Math.min(sample.x.len, sample.y.len), -win: { -x0: sample.x_range[0], x1: sample.x_range[1], -y0: sample.y_range[0], y1: sample.y_range[1], -}, -sample: { n: sample.n, visible: sample.visible }, -selActive: false, -colorMode: 0, -color: parseColor(this.root, sample.color && sample.color.color, [0.3, 0.47, 0.66, 1]), -sizeMode: 0, -size: (sample.size && sample.size.size) || 4.0, -sizeRange: [2, 18], -}; -gl.bindBuffer(gl.ARRAY_BUFFER, s.xBuf); -gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.x.buf]), gl.STATIC_DRAW); -gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); -gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); -if (sample.color && sample.color.buf !== undefined) { -s.colorMode = sample.color.mode === "continuous" ? 1 : 2; -s.cBuf = gl.createBuffer(); -const colorValues = sample.color.dtype === "u8" -? this._asU8(buffers[sample.color.buf]) -: this._asF32(buffers[sample.color.buf]); -s.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, s.cBuf); -gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); -s.lut = sample.color.mode === "continuous" -? this._lut(sample.color.colormap) -: this._paletteLut(sample.color.palette); -} -if (sample.size && sample.size.mode === "continuous") { -s.sizeMode = 1; -s.sBuf = gl.createBuffer(); -gl.bindBuffer(gl.ARRAY_BUFFER, s.sBuf); -gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.size.buf]), gl.STATIC_DRAW); -s.sizeRange = sample.size.range_px; -} -g.sampleOverlay = s; -this._refreshReductionBadges(); -} -_drawDensitySample(g, x0, x1, y0, y1, opacityScale = 1) { -const s = g && g.sampleOverlay; -if (!s || !s.n || !this._viewInside(s.win)) return; -this._drawPoints( -s, -this._map(s.xMeta, x0, x1, s.xAxis), -this._map(s.yMeta, y0, y1, s.yAxis), -opacityScale -); -} -_resolveMarkFill(style, markColor) { -const fill = style && style.fill; -if (!fill || !Array.isArray(fill.stops) || fill.stops.length < 2) return null; -const mode = fill.space === "plot" ? 2 : 1; -const dir = { down: 0, up: 1, left: 2, right: 3 }[fill.dir] ?? 0; -const count = Math.min(fill.stops.length, 8); -const pos = new Float32Array(8); -const colors = new Float32Array(32); -for (let i = 0; i < count; i++) { -const stop = fill.stops[i] || []; -pos[i] = Math.min(Math.max(Number(stop[0]) || 0, 0), 1); -const expr = String(stop[1] || "").trim(); -const c = expr.toLowerCase() === "currentcolor" -? markColor -: parseColor(this.root, expr, markColor); -colors[i * 4] = c[0] * c[3]; -colors[i * 4 + 1] = c[1] * c[3]; -colors[i * 4 + 2] = c[2] * c[3]; -colors[i * 4 + 3] = c[3]; -} -return { mode, dir, count, pos, colors }; -} -_setGradientUniforms(prog, grad) { -const gl = this.gl; -const u = (n) => uniformOf(gl, prog, n); -if (!grad) { -gl.uniform1i(u("u_gradMode"), 0); -return; -} -gl.uniform1i(u("u_gradMode"), grad.mode); -gl.uniform1i(u("u_gradDir"), grad.dir); -gl.uniform1i(u("u_gradCount"), grad.count); -gl.uniform1fv(u("u_gradPos"), grad.pos); -gl.uniform4fv(u("u_gradColor"), grad.colors); -} -_fillOpacity(style, fallback = 1) { -return Number(style.opacity ?? fallback) * Number(style.fill_opacity ?? 1); -} -_strokeOpacity(style, fallback = 1) { -return Number(style.opacity ?? fallback) * Number(style.stroke_opacity ?? 1); -} -_setRectStyleUniforms(prog, g) { -const gl = this.gl; -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); -const cr = g.cornerRadius || [0, 0]; -gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr); -gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); -const sc = g.strokeColor || [0, 0, 0, 0]; -const sa = sc[3] * this._strokeOpacity(g.trace.style || {}); -gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa); -this._setGradientUniforms(prog, g.grad); -} -_rectMarkStyleGpu(g, t) { -const s = t.style || {}; -const cr = s.corner_radius; -g.cornerRadius = Array.isArray(cr) -? [Number(cr[0]) || 0, Number(cr[1]) || 0] -: [Number(cr) || 0, Number(cr) || 0]; -g.strokeWidth = Number(s.stroke_width) || 0; -const opaque = [g.color[0], g.color[1], g.color[2], 1]; -g.strokeColor = s.stroke ? parseColor(this.root, s.stroke, opaque) : opaque; -g.grad = this._resolveMarkFill(s, g.color); -} -_smoothArrays(t, x, y, base, n) { -if (!t.style || t.style.curve !== "smooth") return null; -return fcSmoothResample(x, y, base || null, n, 32768); -} -_stepArrays(t, x, y, n) { -const where = t.style && t.style.step; -if (!where || n < 2) return null; -const perGap = where === "mid" ? 3 : 2; -const m = 1 + (n - 1) * perGap; -const sx = new Float32Array(m); -const sy = new Float32Array(m); -sx[0] = x[0]; -sy[0] = y[0]; -let j = 1; -for (let i = 1; i < n; i++) { -if (where === "pre") { -sx[j] = x[i - 1]; sy[j] = y[i]; j++; -sx[j] = x[i]; sy[j] = y[i]; j++; -} else if (where === "mid") { -const mid = (x[i - 1] + x[i]) * 0.5; -sx[j] = mid; sy[j] = y[i - 1]; j++; -sx[j] = mid; sy[j] = y[i]; j++; -sx[j] = x[i]; sy[j] = y[i]; j++; -} else { -sx[j] = x[i]; sy[j] = y[i - 1]; j++; -sx[j] = x[i]; sy[j] = y[i]; j++; -} -} -return { x: sx, y: sy, n: m }; -} -_buildLineMark(g, t, buffer) { -const x = this._columnView(buffer, this.spec.columns[t.x]); -const y = this._columnView(buffer, this.spec.columns[t.y]); -g.xMeta = { ...this.spec.columns[t.x] }; -g.yMeta = { ...this.spec.columns[t.y] }; -g.n = Math.min(x.length, y.length); -g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; -const sm = this._smoothArrays(t, x, y, null, g.n); -const src = sm || { x, y, n: g.n }; -const st = this._stepArrays(t, src.x, src.y, src.n); -const drawX = st ? st.x : src.x; -const drawY = st ? st.y : src.y; -g.xBuf = this._upload(drawX); -g.yBuf = this._upload(drawY); -g.n = st ? st.n : src.n; -g._dashX = drawX; -g._dashY = drawY; -g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); -} -_buildSegmentMark(g, t, buffer) { -const x0 = this._columnView(buffer, this.spec.columns[t.x0]); -const x1 = this._columnView(buffer, this.spec.columns[t.x1]); -const y0 = this._columnView(buffer, this.spec.columns[t.y0]); -const y1 = this._columnView(buffer, this.spec.columns[t.y1]); -g.x0Meta = { ...this.spec.columns[t.x0] }; -g.x1Meta = { ...this.spec.columns[t.x1] }; -g.y0Meta = { ...this.spec.columns[t.y0] }; -g.y1Meta = { ...this.spec.columns[t.y1] }; -g.n = Math.min(x0.length, x1.length, y0.length, y1.length); -g.x0Buf = this._upload(x0); -g.x1Buf = this._upload(x1); -g.y0Buf = this._upload(y0); -g.y1Buf = this._upload(y1); -g._segmentCpu = { x0, x1, y0, y1 }; -g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); -g.colorMode = 0; -if (t.color && t.color.mode === "continuous") { -g.colorMode = 1; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._lut(t.color.colormap); -} else if (t.color && t.color.mode === "categorical") { -g.colorMode = 2; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._paletteLut(t.color.palette); -} -g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; -} -_buildMeshMark(g, t, buffer) { -for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { -const values = this._columnView(buffer, this.spec.columns[t[name]]); -g[name + "Meta"] = { ...this.spec.columns[t[name]] }; -g[name + "Buf"] = this._upload(values); -g.n = g.n === undefined ? values.length : Math.min(g.n, values.length); -} -g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); -g.colorMode = 0; -if (t.color && t.color.mode === "continuous") { -g.colorMode = 1; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._lut(t.color.colormap); -} else if (t.color && t.color.mode === "categorical") { -g.colorMode = 2; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._paletteLut(t.color.palette); -} -const style = t.style || {}; -g.meshStrokeWidth = Number(style.stroke_width) || 0; -g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); -} -_buildHexbinMark(g, t, buffer) { -const cx = this._columnView(buffer, this.spec.columns[t.x]); -const cy = this._columnView(buffer, this.spec.columns[t.y]); -const xMeta = { ...this.spec.columns[t.x] }; -const yMeta = { ...this.spec.columns[t.y] }; -const n = Math.min(cx.length, cy.length); -const style = t.style || {}; -const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1); -const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1); -const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0]; -const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3]; -const parts = {}; -for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6); -for (let i = 0; i < n; i++) { -const px = cx[i], py = cy[i]; -for (let k = 0; k < 6; k++) { -const j = i * 6 + k; -parts.x0[j] = px; -parts.y0[j] = py; -parts.x1[j] = px + ringX[k]; -parts.y1[j] = py + ringY[k]; -parts.x2[j] = px + ringX[k + 1]; -parts.y2[j] = py + ringY[k + 1]; -} -} -for (const name of ["x0", "x1", "x2"]) { -g[name + "Meta"] = { ...xMeta }; -g[name + "Buf"] = this._upload(parts[name]); -} -for (const name of ["y0", "y1", "y2"]) { -g[name + "Meta"] = { ...yMeta }; -g[name + "Buf"] = this._upload(parts[name]); -} -g.n = n * 6; -g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); -g.colorMode = 0; -if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) { -g.colorMode = t.color.mode === "continuous" ? 1 : 2; -const cval = this._columnView(buffer, this.spec.columns[t.color.buf]); -const expanded = new Float32Array(n * 6); -for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6); -g.cBuf = this._upload(expanded); -g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette); -} -g.meshStrokeWidth = Number(style.stroke_width) || 0; -g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); -} -_buildAreaMark(g, t, buffer) { -const x = this._columnView(buffer, this.spec.columns[t.x]); -const y = this._columnView(buffer, this.spec.columns[t.y]); -const base = this._columnView(buffer, this.spec.columns[t.base]); -g.xMeta = { ...this.spec.columns[t.x] }; -g.yMeta = { ...this.spec.columns[t.y] }; -g.baseMeta = { ...this.spec.columns[t.base] }; -g.n = Math.min(x.length, y.length, base.length); -g._cpu = { x, y, base, xMeta: g.xMeta, yMeta: g.yMeta }; -const sm = this._smoothArrays(t, x, y, base, g.n); -g.xBuf = this._upload(sm ? sm.x : x); -g.yBuf = this._upload(sm ? sm.y : y); -g.baseBuf = this._upload(sm ? sm.extra : base); -if (sm) g.n = sm.n; -g._dashX = sm ? sm.x : x; -g._dashY = sm ? sm.y : y; -g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); -g.lineColor = parseColor(this.root, t.style && (t.style.line_color || t.style.color), g.color); -g.grad = this._resolveMarkFill(t.style, g.color); -} -_buildRectMark(g, t, buffer) { -const x0 = this._columnView(buffer, this.spec.columns[t.x0]); -const x1 = this._columnView(buffer, this.spec.columns[t.x1]); -const y0 = this._columnView(buffer, this.spec.columns[t.y0]); -const y1 = this._columnView(buffer, this.spec.columns[t.y1]); -g.x0Meta = { ...this.spec.columns[t.x0] }; -g.x1Meta = { ...this.spec.columns[t.x1] }; -g.y0Meta = { ...this.spec.columns[t.y0] }; -g.y1Meta = { ...this.spec.columns[t.y1] }; -g.n = Math.min(x0.length, x1.length, y0.length, y1.length); -g._cpuRect = { -x0, x1, y0, y1, -x0Meta: g.x0Meta, x1Meta: g.x1Meta, y0Meta: g.y0Meta, y1Meta: g.y1Meta, -}; -g.x0Buf = this._upload(x0); -g.x1Buf = this._upload(x1); -g.y0Buf = this._upload(y0); -g.y1Buf = this._upload(y1); -g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); -g.colorMode = 0; -if (t.color && t.color.mode === "continuous") { -g.colorMode = 1; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._lut(t.color.colormap); -} else if (t.color && t.color.mode === "categorical") { -g.colorMode = 2; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._paletteLut(t.color.palette); -} -this._rectMarkStyleGpu(g, t); -} -_buildBarMark(g, t, buffer) { -const b = t.bar; -if (!b) return this._buildRectMark(g, t, buffer); -const pos = this._columnView(buffer, this.spec.columns[b.pos]); -const v1 = this._columnView(buffer, this.spec.columns[b.value1]); -g.posMeta = { ...this.spec.columns[b.pos] }; -g.value1Meta = { ...this.spec.columns[b.value1] }; -g.n = Math.min(pos.length, v1.length); -g.posBuf = this._upload(pos); -g.value1Buf = this._upload(v1); -g.orientation = b.orientation === "horizontal" ? 1 : 0; -g.value0Const = b.value0_const ?? 0; -g.value0Mode = b.value0 === undefined ? 0 : 1; -g.width = b.width; -if (g.value0Mode === 1) { -const v0 = this._columnView(buffer, this.spec.columns[b.value0]); -g.value0Meta = { ...this.spec.columns[b.value0] }; -g.n = Math.min(g.n, v0.length); -g._cpuValue0 = v0; -g.value0Buf = this._upload(v0); -} -g._cpu = g.orientation === 1 -? { x: v1, y: pos, xMeta: g.value1Meta, yMeta: g.posMeta, value0: g._cpuValue0 } -: { x: pos, y: v1, xMeta: g.posMeta, yMeta: g.value1Meta, value0: g._cpuValue0 }; -g.color = parseColor(this.root, t.style && t.style.color, [0.3, 0.47, 0.66, 1]); -g.colorMode = 0; -if (t.color && t.color.mode === "continuous") { -g.colorMode = 1; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._lut(t.color.colormap); -} else if (t.color && t.color.mode === "categorical") { -g.colorMode = 2; -g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); -g.lut = this._paletteLut(t.color.palette); -} -this._rectMarkStyleGpu(g, t); -} -_buildHeatmapMark(g, t, buffer) { -const h = t.heatmap; -const truecolor = Array.isArray(h.rgba_bufs); -const grid = truecolor -? h.rgba_bufs.map((index) => this._columnView(buffer, this.spec.columns[index])) -: this._columnView(buffer, this.spec.columns[h.buf]); -g.heatmap = { -w: h.w, -h: h.h, -xRange: h.x_range, -yRange: h.y_range, -colormap: h.colormap, -truecolor, -tex: truecolor ? this._uploadRgbaGrid(grid, h.w, h.h) : this._uploadHeatmapGrid(grid, h.w, h.h), -lut: truecolor ? null : this._lut(h.colormap), -}; -if (!truecolor) g._cpuHeatmap = { grid }; -} -_uploadRgbaGrid(channels, w, h) { -const gl = this.gl; -const tex = gl.createTexture(); -const data = new Uint8Array(w * h * 4); -for (let index = 0; index < w * h; index++) { -for (let channel = 0; channel < 4; channel++) { -data[index * 4 + channel] = Math.round(255 * Math.max(0, Math.min(1, channels[channel][index]))); -} -} -gl.bindTexture(gl.TEXTURE_2D, tex); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -return tex; -} -_uploadGrid(f32, w, h, maxVal) { -const gl = this.gl; -const tex = gl.createTexture(); -lodWriteGridTexture(gl, tex, f32, w, h, maxVal); -return tex; -} -_uploadHeatmapGrid(f32, w, h) { -const gl = this.gl; -const tex = gl.createTexture(); -const data = new Uint8Array(f32.length); -for (let i = 0; i < f32.length; i++) { -const v = f32[i]; -if (Number.isFinite(v)) { -data[i] = Math.max(1, Math.min(255, Math.round(1 + 254 * Math.max(0, Math.min(1, v))))); -} -} -gl.bindTexture(gl.TEXTURE_2D, tex); -const align = gl.getParameter(gl.UNPACK_ALIGNMENT); -gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); -gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); -return tex; -} -_columnView(buffer, meta) { -const split = Array.isArray(buffer); -if (split !== Number.isInteger(meta.buf)) { -throw new Error( -split -? "xy: transport delivered a buffer list but the spec column has no wire-buffer index" -: "xy: spec column carries a wire-buffer index but the transport delivered one blob", -); -} -const span = fcByteSpan(split ? buffer[meta.buf] : buffer, "chart payload"); -const relativeOffset = Number(meta.byte_offset); -const length = Number(meta.len); -if (!Number.isSafeInteger(relativeOffset) || relativeOffset < 0 || -!Number.isSafeInteger(length) || length < 0) { -throw new RangeError("column offset/length must be non-negative safe integers"); -} -const bytesPerElement = meta.dtype === "u8" ? 1 : 4; -const absoluteOffset = span.byteOffset + relativeOffset; -const end = relativeOffset + length * bytesPerElement; -if (end > span.byteLength) throw new RangeError("column extends past chart payload"); -if (absoluteOffset % bytesPerElement !== 0) throw new RangeError("column is misaligned"); -if (meta.dtype === "u8") return new Uint8Array(span.buffer, absoluteOffset, length); -return new Float32Array(span.buffer, absoluteOffset, length); -} -_upload(view) { -const gl = this.gl; -const buf = gl.createBuffer(); -buf._fcId = ++this._bufSeq; -buf._fcType = view instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, buf); -gl.bufferData(gl.ARRAY_BUFFER, view, gl.STATIC_DRAW); -return buf; -} -_bindVao(g, key, parts, setup) { -const gl = this.gl; -if (!g._vaos) g._vaos = new Map(); -const sig = parts.join("|"); -let entry = g._vaos.get(key); -if (!entry || entry.sig !== sig) { -if (entry) gl.deleteVertexArray(entry.vao); -const vao = gl.createVertexArray(); -gl.bindVertexArray(vao); -setup(); -entry = { vao, sig }; -g._vaos.set(key, entry); -} else { -gl.bindVertexArray(entry.vao); -} -} -_deleteVaos(g) { -if (!g || !g._vaos) return; -const gl = this.gl; -if (gl) for (const { vao } of g._vaos.values()) gl.deleteVertexArray(vao); -g._vaos = null; -} -_vaoAttr(slot, buf, byteOffset, divisor, size = 1) { -const gl = this.gl; -gl.bindBuffer(gl.ARRAY_BUFFER, buf); -gl.enableVertexAttribArray(slot); -gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, false, 0, byteOffset); -gl.vertexAttribDivisor(slot, divisor); -} -_initPickTarget() { -const gl = this.gl; -this.pickTex = gl.createTexture(); -this._allocPickTex(); -this.pickFbo = gl.createFramebuffer(); -gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); -gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.pickTex, 0); -gl.bindFramebuffer(gl.FRAMEBUFFER, null); -this._pickDirty = true; -} -_allocPickTex() { -const gl = this.gl; -gl.bindTexture(gl.TEXTURE_2D, this.pickTex); -gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, this.canvas.width, this.canvas.height, 0, -gl.RGBA, gl.UNSIGNED_BYTE, null); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); -gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); -this._pickW = this.canvas.width; -this._pickH = this.canvas.height; -} -_map(meta, lo, hi, axisId = null) { -if (!axisId) { -const mul = 2 / ((hi - lo) * meta.scale); -const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; -return [mul, add]; -} -const axis = this._axis(axisId); -const c0 = this._axisCoord(axis, lo); -const c1 = this._axisCoord(axis, hi); -if (![c0, c1].every(Number.isFinite) || c1 === c0) return [0, -2]; -const mul = 2 / (c1 - c0); -const add = -1 - c0 * mul; -return [mul, add]; -} -_mapConst(value, lo, hi, axisId = null) { -if (!axisId) return ((value - lo) / (hi - lo)) * 2 - 1; -const axis = this._axis(axisId); -const c = this._axisCoord(axis, value); -const c0 = this._axisCoord(axis, lo); -const c1 = this._axisCoord(axis, hi); -if (![c, c0, c1].every(Number.isFinite) || c1 === c0) return -2; -return ((c - c0) / (c1 - c0)) * 2 - 1; -} -_edgePadForValue(value, lo, hi, pixels) { -if (!Number.isFinite(value) || !Number.isFinite(lo) || !Number.isFinite(hi) || hi === lo) return 0; -const span = Math.abs(hi - lo); -const eps = span * 1e-10 + 1e-12; -const px = Math.max(1, pixels || 1); -const padPx = Math.max(2, Math.ceil(this.dpr || 1)); -if (Math.abs(value - lo) <= eps) return -(2 * padPx) / px; -if (Math.abs(value - hi) <= eps) return (2 * padPx) / px; -return 0; -} -_setAxisUniforms(prog, prefix, meta, axisId) { -const gl = this.gl; -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u(`${prefix}meta`), meta && Number.isFinite(meta.offset) ? meta.offset : 0, meta && meta.scale ? meta.scale : 1); -gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); -} -draw(keepPick = false) { -if (this._destroyed || this._glLost || !this.gl) return; -this._updateZoomMenuLabel?.(); -if (this._raf) { -this._rafKeepPick = this._rafKeepPick && keepPick; -return; -} -this._rafKeepPick = keepPick; -this._raf = requestAnimationFrame(() => { -this._raf = null; -if (this._destroyed) return; -this._drawNow(); -}); -} -_drawNow() { -if (this._destroyed || !this.gl || this._glLost) return; -const gl = this.gl; -const { x0, x1, y0, y1 } = this.view; -gl.bindFramebuffer(gl.FRAMEBUFFER, null); -gl.viewport(0, 0, this.canvas.width, this.canvas.height); -gl.clearColor(0, 0, 0, 0); -gl.clear(gl.COLOR_BUFFER_BIT); -for (const g of this.gpuTraces) { -if (g.tier === "density") { -const [gx0, gx1] = this._axisRange(g.xAxis); -const [gy0, gy1] = this._axisRange(g.yAxis); -lodDrawDensityTier(this, g, gx0, gx1, gy0, gy1); -continue; -} -markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1); -} -this._drawHoverState(); -if (!this._rafKeepPick) this._pickDirty = true; -this._rafKeepPick = false; -this._drawChrome(); -this._renderLassoSelection?.(); -} -_now() { -return performance.now(); -} -_drawPoints(g, xm, ym, opacityScale = 1) { -const simple = -g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && -(g.symbol || 0) === 0 && (g.pointStrokeWidth || 0) <= 0 && -Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; -if (simple) { -this._drawSimplePoints(g, xm, ym, opacityScale); -return; -} -const gl = this.gl; -const prog = this.pointProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); -this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); -gl.uniform1f(u("u_dpr"), this.dpr); -gl.uniform1f(u("u_size"), g.size); -gl.uniform1i(u("u_sizeMode"), g.sizeMode); -gl.uniform2f(u("u_sizeRange"), g.sizeRange[0], g.sizeRange[1]); -gl.uniform1i(u("u_colorMode"), g.colorMode); -const markOpacity = this._fillOpacity(g.trace.style, 0.8) * opacityScale; -gl.uniform1f(u("u_opacity"), markOpacity); -gl.uniform1f(u("u_selectedOpacity"), this._markStateNumber("selected", "opacity", 1)); -gl.uniform1f(u("u_unselectedOpacity"), this._markStateNumber("unselected", "opacity", 0.12)); -const stateColor = (loc, expr) => { -const c = expr ? parseColor(this.root, expr, [0, 0, 0, 1]) : null; -gl.uniform4f(loc, c ? c[0] : 0, c ? c[1] : 0, c ? c[2] : 0, c ? 1 : 0); -}; -stateColor(u("u_selColor"), this._markStateValue("selected", "color")); -stateColor(u("u_unselColor"), this._markStateValue("unselected", "color")); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, 1); -gl.uniform1i(u("u_symbol"), g.symbol || 0); -const sc = g.pointStroke; -const strokeAlpha = sc -? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale -: 0; -gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr); -gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0); -gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0, -sc ? sc[2] * strokeAlpha : 0, strokeAlpha); -gl.uniform1i(u("u_selActive"), g.selActive ? 1 : 0); -const colorOn = g.colorMode !== 0 && g.cBuf; -const sizeOn = g.sizeMode === 1 && g.sBuf; -const selOn = g.selActive && g.selBuf; -if (g.lut) { -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, g.lut); -gl.uniform1i(u("u_lut"), 0); -} -const blendTarget = g.lodBlend ?? 0; -let blend = g.lodBlendShown ?? blendTarget; -if (Math.abs(blend - blendTarget) > 0.005 && !this._prefersReducedMotion()) { -const now = this._now(); -const dt = g._blendTick ? Math.min(100, now - g._blendTick) : 16; -g._blendTick = now; -blend += (blendTarget - blend) * (1 - Math.exp(-dt / 90)); -g.lodBlendShown = blend; -this.draw(); -} else { -g.lodBlendShown = blend = blendTarget; -g._blendTick = 0; -} -gl.uniform1f(u("u_dblend"), blend); -const blendOn = blend > 0.001 && g.dBuf && g.dlut; -if (blendOn) { -gl.activeTexture(gl.TEXTURE1); -gl.bindTexture(gl.TEXTURE_2D, g.dlut); -} -gl.uniform1i(u("u_dlut"), 1); -this._bindVao( -g, -"points", -[ -g.xBuf._fcId, g.yBuf._fcId, -colorOn ? g.cBuf._fcId : 0, -sizeOn ? g.sBuf._fcId : 0, -selOn ? g.selBuf._fcId : 0, -blendOn ? g.dBuf._fcId : 0, -], -() => { -this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); -this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); -if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 0); -if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, g.sBuf, 0, 0); -if (selOn) this._vaoAttr(ATTR_SLOTS.a_sel, g.selBuf, 0, 0); -if (blendOn) this._vaoAttr(ATTR_SLOTS.a_dval, g.dBuf, 0, 0); -} -); -if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); -if (!selOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1.0); -if (!blendOn) gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); -gl.drawArrays(gl.POINTS, 0, g.n); -} -_drawSimplePoints(g, xm, ym, opacityScale = 1) { -const gl = this.gl; -const prog = this.pointSimpleProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); -this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); -gl.uniform1f(u("u_dpr"), this.dpr); -gl.uniform1f(u("u_size"), g.size); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale); -this._bindVao( -g, -"points-simple", -[g.xBuf._fcId, g.yBuf._fcId], -() => { -this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); -this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); -} -); -gl.drawArrays(gl.POINTS, 0, g.n); -} -_drawHoverState() { -const hit = this._hoverTarget; -if (!hit || !hit.g) return; -const g = hit.g; -if (g.trace.kind !== "scatter" || g.tier === "density") return; -if (!Number.isInteger(hit.index) || hit.index < 0 || hit.index >= g.n) return; -const [x0, x1] = this._axisRange(g.xAxis); -const [y0, y1] = this._axisRange(g.yAxis); -this._drawHoverPoint( -g, -hit.index, -this._map(g.xMeta, x0, x1, g.xAxis), -this._map(g.yMeta, y0, y1, g.yAxis) -); -} -_drawHoverPoint(g, index, xm, ym) { -const gl = this.gl; -const prog = this.pointProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); -this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); -const defaultSize = Math.max((g.size || 4) * 1.75, (g.size || 4) + 5); -const size = Math.max(0, this._markStateNumber("hover", "size", defaultSize)); -const opacity = Math.max(0, Math.min(1, this._markStateNumber("hover", "opacity", 0.95))); -const color = parseColor( -this.root, -this._markStatePaint("hover", "color", "rgba(15,23,42,.92)"), -[0.06, 0.09, 0.16, 0.92] -); -gl.uniform1f(u("u_dpr"), this.dpr); -gl.uniform1f(u("u_size"), size); -gl.uniform1i(u("u_sizeMode"), 0); -gl.uniform2f(u("u_sizeRange"), size, size); -gl.uniform1i(u("u_colorMode"), 0); -gl.uniform1f(u("u_opacity"), opacity); -gl.uniform1f(u("u_selectedOpacity"), 1); -gl.uniform1f(u("u_unselectedOpacity"), 1); -gl.uniform4f(u("u_color"), color[0], color[1], color[2], 1); -gl.uniform1i(u("u_selActive"), 0); -gl.uniform1f(u("u_dblend"), 0); -this._bindVao(g, "hover", [g.xBuf._fcId, g.yBuf._fcId], () => { -this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); -this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); -}); -gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); -gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1); -gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); -gl.drawArrays(gl.POINTS, index, 1); -} -_drawDensity(g, density, opacityScale = 1) { -const gl = this.gl; -const prog = this.densityProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -const { x0, x1, y0, y1 } = this.view; -const [vx0, vx1] = this._axisRange(g.xAxis); -const [vy0, vy1] = this._axisRange(g.yAxis); -gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); -gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); -gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -const d = density || g.density; -gl.uniform4f(u("u_gridRange"), d.xRange[0], d.xRange[1], d.yRange[0], d.yRange[1]); -gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale); -const constant = d.color; -gl.uniform1i(u("u_constantColor"), constant ? 1 : 0); -gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1])); -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, d.tex); -gl.uniform1i(u("u_grid"), 0); -gl.activeTexture(gl.TEXTURE1); -gl.bindTexture(gl.TEXTURE_2D, d.lut); -gl.uniform1i(u("u_lut"), 1); -gl.bindVertexArray(this.quadVao); -gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); -} -_drawHeatmap(g) { -const h = g.heatmap; -if (!h) return; -const gl = this.gl; -const prog = this.heatmapProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -const { x0, x1, y0, y1 } = this.view; -const [vx0, vx1] = this._axisRange(g.xAxis); -const [vy0, vy1] = this._axisRange(g.yAxis); -gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); -gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); -gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -const xrev = (vx0 ?? x0) > (vx1 ?? x1); -const yrev = (vy0 ?? y0) > (vy1 ?? y1); -gl.uniform4f( -u("u_gridRange"), -h.xRange[xrev ? 1 : 0], h.xRange[xrev ? 0 : 1], -h.yRange[yrev ? 1 : 0], h.yRange[yrev ? 0 : 1], -); -gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); -gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, h.tex); -gl.uniform1i(u("u_grid"), 0); -if (!h.truecolor) { -gl.activeTexture(gl.TEXTURE1); -gl.bindTexture(gl.TEXTURE_2D, h.lut); -gl.uniform1i(u("u_lut"), 1); -} -gl.bindVertexArray(this.quadVao); -gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); -} -_drawLine(g, xm, ym, color = null, width = null, opacity = null) { -if (g.n < 2) return; -const gl = this.gl; -gl.useProgram(this.lineProg); -const u = (n) => uniformOf(gl, this.lineProg, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(this.lineProg, "u_x", g.xMeta, g.xAxis); -this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis); -gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); -gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr); -const [r, gg, b, a] = color || g.color; -const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1); -gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity); -const dashed = this._lineDash(g); -this._bindVao( -g, -"line", -dashed ? [g.xBuf._fcId, g.yBuf._fcId, g._lenBuf._fcId] : [g.xBuf._fcId, g.yBuf._fcId], -() => { -this._vaoAttr(ATTR_SLOTS.ax0, g.xBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ax1, g.xBuf, 4, 1); -this._vaoAttr(ATTR_SLOTS.ay0, g.yBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay1, g.yBuf, 4, 1); -if (dashed) { -this._vaoAttr(ATTR_SLOTS.a_len0, g._lenBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.a_len1, g._lenBuf, 4, 1); -} -} -); -gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n - 1); -} -_drawSegments(g, xm, ym) { -if (g.n < 1) return; -const gl = this.gl; -const prog = this.segmentProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); -this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); -this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); -this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); -gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); -gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); -const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style)); -gl.uniform1i(u("u_colorMode"), g.colorMode || 0); -const dashed = this._segmentDash(g, prog); -if (g.colorMode && g.lut) { -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, g.lut); -gl.uniform1i(u("u_lut"), 0); -} -this._bindVao( -g, -"segment", -[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, -g.colorMode ? g.cBuf._fcId : 0, -dashed ? g._segmentDashOffsetBuf._fcId : 0, -dashed ? g._segmentDashDirBuf._fcId : 0], -() => { -this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); -if (dashed) { -this._vaoAttr(ATTR_SLOTS.a_dash0, g._segmentDashOffsetBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.a_dashDir, g._segmentDashDirBuf, 0, 1); -} -} -); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); -} -_segmentDash(g, prog) { -const gl = this.gl; -const u = (n) => uniformOf(gl, prog, n); -const dash = g.trace.style && g.trace.style.dash; -const cpu = g._segmentCpu; -if (!dash || !dash.length || !cpu) { -gl.uniform1i(u("u_dashCount"), 0); -return false; -} -const n = g.n; -const offsets = g._segmentDashOffsets?.length === n -? g._segmentDashOffsets : (g._segmentDashOffsets = new Float32Array(n)); -const directions = g._segmentDashDirections?.length === n -? g._segmentDashDirections : (g._segmentDashDirections = new Float32Array(n)); -const k0 = new Array(n), k1 = new Array(n), lengths = new Float32Array(n); -const adjacency = new Map(); -const add = (key, index) => { -const edges = adjacency.get(key); -if (edges) edges.push(index); else adjacency.set(key, [index]); -}; -const key = (x, y) => `${Math.round(x * 1000)},${Math.round(y * 1000)}`; -const dpr = this.dpr; -for (let i = 0; i < n; i++) { -const x0 = this._dataPx(g.xAxis, this._decodeValue(cpu.x0, g.x0Meta, i)); -const x1 = this._dataPx(g.xAxis, this._decodeValue(cpu.x1, g.x1Meta, i)); -const y0 = this._dataPx(g.yAxis, this._decodeValue(cpu.y0, g.y0Meta, i)); -const y1 = this._dataPx(g.yAxis, this._decodeValue(cpu.y1, g.y1Meta, i)); -k0[i] = key(x0, y0); k1[i] = key(x1, y1); -lengths[i] = Math.hypot(x1 - x0, y1 - y0) * dpr; -add(k0[i], i); add(k1[i], i); -} -const visited = new Uint8Array(n); -const walk = (start) => { -let current = start, accumulated = 0; -while (true) { -const edge = (adjacency.get(current) || []).find((index) => !visited[index]); -if (edge === undefined) break; -visited[edge] = 1; -if (k0[edge] === current) { -offsets[edge] = accumulated; -directions[edge] = 1; -current = k1[edge]; -} else { -offsets[edge] = accumulated + lengths[edge]; -directions[edge] = -1; -current = k0[edge]; -} -accumulated += lengths[edge]; -} -}; -for (const [node, edges] of adjacency) if (edges.length === 1) walk(node); -for (let i = 0; i < n; i++) if (!visited[i]) walk(k0[i]); -const upload = (buffer, values) => { -if (!buffer) return this._upload(values); -gl.bindBuffer(gl.ARRAY_BUFFER, buffer); -gl.bufferData(gl.ARRAY_BUFFER, values, gl.DYNAMIC_DRAW); -return buffer; -}; -g._segmentDashOffsetBuf = upload(g._segmentDashOffsetBuf, offsets); -g._segmentDashDirBuf = upload(g._segmentDashDirBuf, directions); -const pattern = new Float32Array(8); -const count = Math.min(dash.length, 8); -let period = 0; -for (let i = 0; i < count; i++) { -pattern[i] = Number(dash[i]) * dpr; -period += pattern[i]; -} -gl.uniform1i(u("u_dashCount"), count); -gl.uniform1fv(u("u_dashArr"), pattern); -gl.uniform1f(u("u_dashPeriod"), Math.max(period, 1e-3)); -return true; -} -_drawMesh(g, xm, ym) { -if (g.n < 1) return; -const gl = this.gl; -const prog = this.meshProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis); -for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); -gl.uniform1i(u("u_colorMode"), g.colorMode || 0); -gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); -gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1); -const stroke = g.meshStroke || [0, 0, 0, 0]; -const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style); -gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha, -stroke[2] * strokeAlpha, strokeAlpha); -gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); -if (g.colorMode && g.lut) { -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, g.lut); -gl.uniform1i(u("u_lut"), 0); -} -const parts = ["x0", "x1", "x2", "y0", "y1", "y2"].map((name) => g[name + "Buf"]._fcId); -parts.push(g.colorMode ? g.cBuf._fcId : 0); -this._bindVao(g, "mesh", parts, () => { -for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { -this._vaoAttr(ATTR_SLOTS["a" + name], g[name + "Buf"], 0, 1); -} -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); -}); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, g.n); -} -_lineDash(g) { -const gl = this.gl; -const u = (n) => uniformOf(gl, this.lineProg, n); -const dash = g.trace.style && g.trace.style.dash; -if (!dash || !dash.length || !g._dashX) { -gl.uniform1i(u("u_dashCount"), 0); -return false; -} -const n = g.n; -if (!g._lenArr || g._lenArr.length !== n) g._lenArr = new Float32Array(n); -const lens = g._lenArr; -const dpr = this.dpr; -let px = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, 0)); -let py = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, 0)); -let acc = 0; -lens[0] = 0; -for (let i = 1; i < n; i++) { -const nx = this._dataPx(g.xAxis, this._decodeValue(g._dashX, g.xMeta, i)); -const ny = this._dataPx(g.yAxis, this._decodeValue(g._dashY, g.yMeta, i)); -if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(px) && Number.isFinite(py)) { -acc += Math.hypot(nx - px, ny - py) * dpr; -} -lens[i] = acc; -px = nx; -py = ny; -} -if (!g._lenBuf) g._lenBuf = this._upload(lens); -else { -gl.bindBuffer(gl.ARRAY_BUFFER, g._lenBuf); -gl.bufferData(gl.ARRAY_BUFFER, lens, gl.DYNAMIC_DRAW); -} -const arr = new Float32Array(8); -let period = 0; -const count = Math.min(dash.length, 8); -for (let i = 0; i < count; i++) { -arr[i] = dash[i] * dpr; -period += arr[i]; -} -gl.uniform1i(u("u_dashCount"), count); -gl.uniform1fv(u("u_dashArr"), arr); -gl.uniform1f(u("u_dashPeriod"), Math.max(period, 1e-3)); -return true; -} -_drawArea(g, xm, ym, bm) { -if (g.n < 2) return; -const gl = this.gl; -const prog = this.areaProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -gl.uniform2f(u("u_bmap"), bm[0], bm[1]); -this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); -this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); -this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis); -const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.35)); -gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); -this._setGradientUniforms(prog, g.grad); -this._bindVao(g, "area", [g.xBuf._fcId, g.yBuf._fcId, g.baseBuf._fcId], () => { -this._vaoAttr(ATTR_SLOTS.ax0, g.xBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ax1, g.xBuf, 4, 1); -this._vaoAttr(ATTR_SLOTS.ay0, g.yBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay1, g.yBuf, 4, 1); -this._vaoAttr(ATTR_SLOTS.ab0, g.baseBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ab1, g.baseBuf, 4, 1); -}); -gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n - 1); -} -_drawRects(g, x0, x1, y0, y1, edgePad = [0, 0, 0, 0]) { -if (!g.n) return; -const gl = this.gl; -const prog = this.rectProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_x0map"), x0[0], x0[1]); -gl.uniform2f(u("u_x1map"), x1[0], x1[1]); -gl.uniform2f(u("u_y0map"), y0[0], y0[1]); -gl.uniform2f(u("u_y1map"), y1[0], y1[1]); -this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); -this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); -this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); -this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); -gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); -gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); -const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); -gl.uniform1i(u("u_colorMode"), g.colorMode || 0); -this._setRectStyleUniforms(prog, g); -const colorOn = g.colorMode && g.cBuf; -if (colorOn) { -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, g.lut); -gl.uniform1i(u("u_lut"), 0); -} -this._bindVao( -g, -"rects", -[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, colorOn ? g.cBuf._fcId : 0], -() => { -this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); -this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); -if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); -} -); -if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); -} -_drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { -if (!g.n) return; -const gl = this.gl; -const prog = this.barProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform2f(u("u_pmap"), pmap[0], pmap[1]); -gl.uniform2f(u("u_v1map"), v1map[0], v1map[1]); -gl.uniform2f(u("u_v0map"), v0map ? v0map[0] : 1, v0map ? v0map[1] : 0); -const pAxis = g.orientation === 1 ? g.yAxis : g.xAxis; -const vAxis = g.orientation === 1 ? g.xAxis : g.yAxis; -this._setAxisUniforms(prog, "u_p", g.posMeta, pAxis); -this._setAxisUniforms(prog, "u_v1", g.value1Meta, vAxis); -this._setAxisUniforms(prog, "u_v0", g.value0Meta, vAxis); -gl.uniform1i(u("u_pmode"), this._axisMode(pAxis)); -gl.uniform1i(u("u_vmode"), this._axisMode(vAxis)); -gl.uniform1f(u("u_width"), g.width); -gl.uniform1i(u("u_orientation"), g.orientation); -gl.uniform1i(u("u_v0Mode"), g.value0Mode); -gl.uniform1f(u("u_v0Const"), v0Const ?? 0); -gl.uniform1f(u("u_v0EdgePad"), v0EdgePad); -const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); -gl.uniform1i(u("u_colorMode"), g.colorMode || 0); -this._setRectStyleUniforms(prog, g); -const v0On = g.value0Mode === 1 && g.value0Buf; -const colorOn = g.colorMode && g.cBuf; -if (colorOn) { -gl.activeTexture(gl.TEXTURE0); -gl.bindTexture(gl.TEXTURE_2D, g.lut); -gl.uniform1i(u("u_lut"), 0); -} -this._bindVao( -g, -"bars", -[ -g.posBuf._fcId, g.value1Buf._fcId, -v0On ? g.value0Buf._fcId : 0, -colorOn ? g.cBuf._fcId : 0, -], -() => { -this._vaoAttr(ATTR_SLOTS.a_pos, g.posBuf, 0, 1); -this._vaoAttr(ATTR_SLOTS.a_v1, g.value1Buf, 0, 1); -if (v0On) this._vaoAttr(ATTR_SLOTS.a_v0, g.value0Buf, 0, 1); -if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); -} -); -if (!v0On) gl.vertexAttrib1f(ATTR_SLOTS.a_v0, 0); -if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); -gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); -} -_dataPxX(value) { -return this._dataPx("x", value); -} -_dataPxY(value) { -return this._dataPx("y", value); -} -_styleNumber(style, key, fallback) { -if (!style || typeof style !== "object") return fallback; -const value = Number(style[key]); -return Number.isFinite(value) ? value : fallback; -} -_axisStyleNumber(axis, key, fallback) { -return this._styleNumber(axis && axis.style, key, fallback); -} -_axisStylePaint(axis, key, fallback) { -const style = axis && typeof axis.style === "object" ? axis.style : null; -return safeCssPaint(this.root, style && style[key], fallback); -} -_axisStyleValue(axis, key) { -const style = axis && typeof axis.style === "object" ? axis.style : null; -return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined; -} -_axisGridDash(axis) { -const value = String(this._axisStyleValue(axis, "grid_dash") || "solid"); -if (value === "dashed") return [6, 4]; -if (value === "dotted") return [1, 3]; -if (value === "dashdot") return [6, 3, 1, 3]; -return []; -} -_axisTickLabelStrategy(axis) { -const raw = axis && axis.tick_label_strategy !== undefined -? axis.tick_label_strategy -: this._axisStyleValue(axis, "tick_label_strategy"); -const value = String(raw || "auto").replace(/-/g, "_"); -return ["auto", "hide", "rotate", "stagger", "none", "off"].includes(value) ? value : "auto"; -} -_axisTickLabelAngle(axis) { -const raw = axis && axis.tick_label_angle !== undefined -? axis.tick_label_angle -: this._axisStyleValue(axis, "tick_label_angle"); -const angle = Number(raw); -return Number.isFinite(angle) ? angle : null; -} -_axisTickLabelMinGap(axis, dim) { -const raw = axis && axis.tick_label_min_gap !== undefined -? axis.tick_label_min_gap -: this._axisStyleValue(axis, "tick_label_min_gap"); -const gap = Number(raw); -return Number.isFinite(gap) && gap >= 0 ? gap : (dim === "x" ? 8 : 4); -} -_estimateTickLabel(text, fontSize) { -const s = String(text || ""); -return { w: Math.max(fontSize * 0.7, s.length * fontSize * 0.62), h: fontSize * 1.2 }; -} -_tickLabelExtent(label, dim, fontSize) { -const size = this._estimateTickLabel(label.text, fontSize); -const angle = Math.abs(Number(label.angle || 0)) * Math.PI / 180; -return dim === "y" -? Math.abs(Math.sin(angle)) * size.w + Math.abs(Math.cos(angle)) * size.h -: Math.abs(Math.cos(angle)) * size.w + Math.abs(Math.sin(angle)) * size.h; -} -_tickLabelsCollide(labels, dim, fontSize, minGap) { -const rows = new Map(); -for (const label of labels) { -const row = Number(label.row || 0); -if (!rows.has(row)) rows.set(row, []); -rows.get(row).push(label); -} -for (const rowLabels of rows.values()) { -rowLabels.sort((a, b) => a.pos - b.pos); -let lastEnd = -Infinity; -for (const label of rowLabels) { -const extent = this._tickLabelExtent(label, dim, fontSize); -const start = label.pos - extent / 2; -const end = label.pos + extent / 2; -if (start < lastEnd + minGap) return true; -lastEnd = end; -} -} -return false; -} -_downsampleTickLabels(labels, dim, fontSize, minGap) { -if (labels.length <= 1) return labels; -for (let stride = 2; stride <= labels.length; stride++) { -const out = labels.filter((_, i) => i % stride === 0); -if (!this._tickLabelsCollide(out, dim, fontSize, minGap)) return out; -} -return labels.slice(0, 1); -} -_layoutTickLabels(axis, dim, labels) { -if (labels.length <= 1) return labels.map((label) => ({ ...label, angle: 0, row: 0 })); -const fontSize = Math.max( -8, -this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)), -); -const minGap = this._axisTickLabelMinGap(axis, dim); -const explicitAngle = this._axisTickLabelAngle(axis); -const baseAngle = explicitAngle === null ? 0 : explicitAngle; -const withBase = labels.map((label) => ({ ...label, angle: baseAngle, row: 0 })); -let strategy = this._axisTickLabelStrategy(axis); -if (strategy === "none") return []; -if (strategy === "off") return []; -if (strategy === "auto") { -if (!this._tickLabelsCollide(withBase, dim, fontSize, minGap)) return withBase; -if (dim === "x" && axis.kind === "category" && labels.length <= 16) strategy = "rotate"; -else if (dim === "x" && labels.length <= 24) strategy = "stagger"; -else strategy = "hide"; -} -let out = withBase; -if (strategy === "rotate" && dim === "x") { -const angle = explicitAngle === null ? (axis.side === "top" ? 35 : -35) : explicitAngle; -out = labels.map((label) => ({ ...label, angle, row: 0 })); -} else if (strategy === "stagger" && dim === "x") { -out = labels.map((label, i) => ({ ...label, angle: baseAngle, row: i % 2 })); -} -if (strategy === "hide" || this._tickLabelsCollide(out, dim, fontSize, minGap)) { -out = this._downsampleTickLabels(out, dim, fontSize, minGap); -} -return out; -} -_axisLabelCss(axis, dim, fallbackCss) { -const rawPosition = axis && axis.label_position; -const hasPosition = rawPosition !== undefined && rawPosition !== null; -const hasOffset = axis && Number.isFinite(Number(axis.label_offset)); -const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); -if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; -if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { -return { css: "font-weight:500;white-space:nowrap;", style: rawPosition }; -} -const p = this.plot; -const position = String(hasPosition ? rawPosition : "center").replace(/-/g, "_"); -const inside = position.startsWith("inside_"); -const anchor = inside ? position.slice("inside_".length) : position; -const offset = hasOffset ? Number(axis.label_offset) : 0; -const side = axis && axis.side; -const anchorFrac = anchor === "start" ? 0 : (anchor === "end" ? 1 : 0.5); -if (dim === "x") { -const x = p.x + p.w * anchorFrac; -const outsideY = side === "top" ? p.y - 34 : p.y + p.h + 24; -const insideY = side === "top" ? p.y + 12 : p.y + p.h - 12; -const y = (inside ? insideY : outsideY) + -(side === "top" ? (inside ? offset : -offset) : (inside ? -offset : offset)); -const translateX = anchor === "start" ? 0 : (anchor === "end" ? -100 : -50); -const angle = hasAngle ? Number(axis.label_angle) : 0; -return { -css: -`left:${x}px;top:${y}px;` + -`transform:translateX(${translateX}%) rotate(${angle}deg);` + -"transform-origin:center;font-weight:500;white-space:nowrap;", -style: null, -}; -} -const xOutside = side === "right" ? p.x + p.w + 40 : 10; -const xInside = side === "right" ? p.x + p.w - 12 : p.x + 12; -const x = (inside ? xInside : xOutside) + -(side === "right" ? (inside ? -offset : offset) : (inside ? offset : -offset)); -const y = p.y + p.h * (1 - anchorFrac); -const angle = hasAngle ? Number(axis.label_angle) : (side === "right" ? 90 : -90); -return { -css: -`left:${x}px;top:${y}px;` + -`transform:translate(-50%,-50%) rotate(${angle}deg);` + -"transform-origin:center;font-weight:500;white-space:nowrap;", -style: null, -}; -} -_drawChrome() { -const s = this.spec; -const dpr = this.dpr; -const ctx = this.chrome.getContext("2d"); -ctx.setTransform(dpr, 0, 0, dpr, 0, 0); -ctx.clearRect(0, 0, this.size.w, this.size.h); -const now = this._now(); -const labelCadenceMs = this._viewAnim ? 80 : 0; -const updateLabels = labelCadenceMs === 0 -|| this._lastLabelDraw === null -|| now - this._lastLabelDraw >= labelCadenceMs; -if (updateLabels) { -this.labels.textContent = ""; -this._lastLabelDraw = now; -} -const p = this.plot; -if (this.theme.bg) { -ctx.fillStyle = cssColor(this.theme.bg); -ctx.fillRect(p.x, p.y, p.w, p.h); -} -const xAxis = this._axis("x"); -const yAxis = this._axis("y"); -const hideX = this._axisTickLabelStrategy(xAxis) === "none"; -const hideY = this._axisTickLabelStrategy(yAxis) === "none"; -const xt = this._axisTicks( -"x", -this._axisTickTarget("x", Math.max(3, p.w / (xAxis.kind === "time" ? 90 : 80))), -); -const yt = this._axisTicks("y", this._axisTickTarget("y", Math.max(3, p.h / 45))); -const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(px) + 0.5)); -const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5)); -ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid); -ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1)); -ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1); -ctx.setLineDash(this._axisGridDash(xAxis)); -ctx.beginPath(); -for (const v of (hideX ? [] : xt.ticks)) { -const px = this._dataPx("x", v); -if (!Number.isFinite(px)) continue; -const x = xEdge(px); -ctx.moveTo(x, p.y); -ctx.lineTo(x, p.y + p.h); -} -ctx.stroke(); -ctx.strokeStyle = this._axisStylePaint(yAxis, "grid_color", this.theme.grid); -ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(yAxis, "grid_width", 1)); -ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1); -ctx.setLineDash(this._axisGridDash(yAxis)); -ctx.beginPath(); -for (const v of (hideY ? [] : yt.ticks)) { -const py = this._dataPx("y", v); -if (!Number.isFinite(py)) continue; -const y = yEdge(py); -ctx.moveTo(p.x, y); -ctx.lineTo(p.x + p.w, y); -} -ctx.stroke(); -ctx.globalAlpha = 1; -ctx.setLineDash([]); -this._drawAnnotationShapes(ctx); -if (updateLabels) { -const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => { -const d = document.createElement("div"); -d.style.cssText = -`position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;` + -`background:${this._axisStylePaint(styleAxis, colorKey, this.theme.axis)};` + -"pointer-events:none;"; -this.labels.appendChild(d); -}; -const frameSides = Array.isArray(s.frame_sides) -? s.frame_sides -: [xAxis.side || "bottom", yAxis.side || "left"]; -if (!hideY) { -const yWidth = Math.max(1, this._axisStyleNumber(yAxis, "axis_width", 1)); -if (frameSides.includes("left")) rule(yAxis, p.x, p.y, yWidth, p.h); -if (frameSides.includes("right")) rule(yAxis, p.x + p.w - yWidth, p.y, yWidth, p.h); -} -if (!hideX) { -const xHeight = Math.max(1, this._axisStyleNumber(xAxis, "axis_width", 1)); -if (frameSides.includes("top")) rule(xAxis, p.x, p.y, p.w, xHeight); -if (frameSides.includes("bottom")) rule(xAxis, p.x, p.y + p.h - xHeight, p.w, xHeight); -} -for (const axis of Object.values(this.axes)) { -if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; -const w = Math.max(1, this._axisStyleNumber(axis, "axis_width", 1)); -const x = axis.side === "left" ? p.x : p.x + p.w - w; -rule(axis, x, p.y, w, p.h); -} -const tickParts = (axis) => { -const length = Math.max(0, this._axisStyleNumber(axis, "tick_length", 0)); -const width = Math.max(0.5, this._axisStyleNumber(axis, "tick_width", 1)); -const direction = String(this._axisStyleValue(axis, "tick_direction") || "out"); -if (direction === "in") return { inward: length, outward: 0, width }; -if (direction === "inout") return { inward: length / 2, outward: length / 2, width }; -return { inward: 0, outward: length, width }; -}; -if (!hideX) { -const tick = tickParts(xAxis); -const side = xAxis.side || "bottom"; -const edge = side === "top" ? p.y : p.y + p.h; -for (const value of xt.ticks) { -const x = this._dataPx("x", value); -if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue; -const top = side === "top" ? edge - tick.outward : edge - tick.inward; -rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color"); -} -} -if (!hideY) { -const tick = tickParts(yAxis); -const side = yAxis.side || "left"; -const edge = side === "right" ? p.x + p.w : p.x; -for (const value of yt.ticks) { -const y = this._dataPx("y", value); -if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue; -const left = side === "right" ? edge - tick.inward : edge - tick.outward; -rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color"); -} -} -} -const label = (text, css, axis, kind = "tick", extraStyle = null) => { -if (!updateLabels) return; -const d = document.createElement("div"); -d.textContent = text; -d.dataset.fcLabelKind = kind; -d.dataset.fcAxis = axis && axis.id !== undefined ? String(axis.id) : ""; -d.dataset.fcAxisSide = axis && axis.side ? String(axis.side) : ""; -const colorKey = kind === "label" -? "label_color" -: (this._axisStyleValue(axis, "tick_label_color") !== undefined -? "tick_label_color" : "tick_color"); -const sizeKey = kind === "label" -? "label_size" -: (this._axisStyleValue(axis, "tick_label_size") !== undefined -? "tick_label_size" : "tick_size"); -let color = ""; -if (this._axisStyleValue(axis, colorKey) !== undefined) { -color = `color:${this._axisStylePaint(axis, colorKey, this.theme.label)};`; -} -let size = ""; -if (this._axisStyleValue(axis, sizeKey) !== undefined) { -size = `font-size:${Math.max(8, this._axisStyleNumber(axis, sizeKey, 11))}px;`; -} -d.style.cssText = `position:absolute;line-height:1.2;white-space:nowrap;${color}${size}${css}`; -this._applySlot(d, kind === "label" ? "axis_title" : "tick_label"); -this._applyStyle(d, extraStyle); -this.labels.appendChild(d); -}; -const xLabelCandidates = []; -for (const v of (xt.labels || xt.ticks)) { -const px = this._dataPx("x", v); -if (px < p.x - 1 || px > p.x + p.w + 1) continue; -const text = this._axisTickText(xAxis, v, xt.step); -xLabelCandidates.push({ pos: px, text }); -} -for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) { -const tickLabelSize = this._axisStyleNumber( -xAxis, -"tick_label_size", -this._axisStyleNumber(xAxis, "tick_size", 11), -); -const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4); -const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset; -const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`; -const origin = xAxis.side === "top" ? "bottom center" : "top center"; -label( -item.text, -`left:${item.pos}px;top:${top}px;transform:${transform};transform-origin:${origin};`, -xAxis, -); -} -const yLabelCandidates = []; -for (const v of (yt.labels || yt.ticks)) { -const py = this._dataPx("y", v); -if (py < p.y - 1 || py > p.y + p.h + 1) continue; -const text = this._axisTickText(yAxis, v, yt.step); -yLabelCandidates.push({ pos: py, text }); -} -for (const item of this._layoutTickLabels(yAxis, "y", yLabelCandidates)) { -const angle = Number(item.angle || 0); -const css = yAxis.side === "right" -? `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;` -: `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;`; -label(item.text, css, yAxis); -} -for (const axis of Object.values(this.axes)) { -if (!axis || axis.id === "y" || !String(axis.id || "").startsWith("y")) continue; -const ticks = this._axisTicks(axis.id, this._axisTickTarget(axis.id, Math.max(3, p.h / 45))); -const labelCandidates = []; -for (const v of (ticks.labels || ticks.ticks)) { -const py = this._dataPx(axis.id, v); -if (py < p.y - 1 || py > p.y + p.h + 1) continue; -const text = this._axisTickText(axis, v, ticks.step); -labelCandidates.push({ pos: py, text }); -} -for (const item of this._layoutTickLabels(axis, "y", labelCandidates)) { -const angle = Number(item.angle || 0); -const css = axis.side === "left" -? `right:${this.size.w - p.x + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:right center;` -: `left:${p.x + p.w + 8}px;top:${item.pos}px;transform:translateY(-50%) rotate(${angle}deg);transform-origin:left center;`; -label(item.text, css, axis); -} -if (axis.label) { -const fallbackCss = axis.side === "left" -? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;` -: `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`; -const placement = this._axisLabelCss(axis, "y", fallbackCss); -label(axis.label, placement.css, axis, "label", placement.style); -} -} -if (s.x_axis.label) { -const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; -const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; -const placement = this._axisLabelCss(xAxis, "x", fallbackCss); -label(s.x_axis.label, placement.css, xAxis, "label", placement.style); -} -if (s.y_axis.label) { -const fallbackCss = yAxis.side === "right" -? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;` -: `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`; -const placement = this._axisLabelCss(yAxis, "y", fallbackCss); -label(s.y_axis.label, placement.css, yAxis, "label", placement.style); -} -this._drawAnnotationLabels(updateLabels); -} -_transitionActive() { -const activeStart = (v) => v !== undefined && v !== null; -return !!this._viewAnim || this.gpuTraces.some((g) => -activeStart(g._densityFadeStart) || -activeStart(g._densitySwitchFadeStart) || -activeStart(g._drillFadeStart) || -activeStart(g._drillExitFadeStart) || -!!g._densityNormAnim); -} -_renderPick() { -const gl = this.gl; -if (this._pickW !== this.canvas.width || this._pickH !== this.canvas.height) { -this._allocPickTex(); -} -gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); -gl.viewport(0, 0, this.canvas.width, this.canvas.height); -gl.disable(gl.BLEND); -gl.clearColor(0, 0, 0, 0); -gl.clear(gl.COLOR_BUFFER_BIT); -const { x0, x1, y0, y1 } = this.view; -const prog = this.pickProg; -gl.useProgram(prog); -const u = (n) => uniformOf(gl, prog, n); -gl.uniform1f(u("u_dpr"), this.dpr); -let base = 1; -for (const g of this.gpuTraces) { -const pg = g.tier === "density" -? (g.drill && !g._drillDying && this._viewInside(g.drill.win) ? g.drill : null) -: (markOf(g.trace.kind).pointPick ? g : null); -if (!pg || !pg.n || base + pg.n > 0x7fffffff) { -g.pickBase = -1; -g.pickCount = 0; -continue; -} -const [px0, px1] = this._axisRange(pg.xAxis || g.xAxis); -const [py0, py1] = this._axisRange(pg.yAxis || g.yAxis); -const xm = this._map(pg.xMeta, px0, px1, pg.xAxis || g.xAxis); -const ym = this._map(pg.yMeta, py0, py1, pg.yAxis || g.yAxis); -gl.uniform2f(u("u_xmap"), xm[0], xm[1]); -gl.uniform2f(u("u_ymap"), ym[0], ym[1]); -this._setAxisUniforms(prog, "u_x", pg.xMeta, pg.xAxis || g.xAxis); -this._setAxisUniforms(prog, "u_y", pg.yMeta, pg.yAxis || g.yAxis); -gl.uniform1f(u("u_size"), pg.size); -gl.uniform1i(u("u_sizeMode"), pg.sizeMode); -gl.uniform2f(u("u_sizeRange"), pg.sizeRange[0], pg.sizeRange[1]); -gl.uniform1i(u("u_pick_base"), base); -g.pickBase = base; -g.pickCount = pg.n; -const sizeOn = pg.sizeMode === 1 && pg.sBuf; -this._bindVao( -pg, -"pick", -[pg.xBuf._fcId, pg.yBuf._fcId, sizeOn ? pg.sBuf._fcId : 0], -() => { -this._vaoAttr(ATTR_SLOTS.ax, pg.xBuf, 0, 0); -this._vaoAttr(ATTR_SLOTS.ay, pg.yBuf, 0, 0); -if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, pg.sBuf, 0, 0); -} -); -if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); -gl.drawArrays(gl.POINTS, 0, pg.n); -base += pg.n; -} -gl.enable(gl.BLEND); -gl.bindFramebuffer(gl.FRAMEBUFFER, null); -this._pickDirty = false; -} -_pickAt(cssX, cssY) { -if (!this._pickable) return null; -if (this._pickDirty) this._renderPick(); -const gl = this.gl; -const px = Math.round(cssX * this.dpr); -const py = Math.round((this.plot.h - cssY) * this.dpr); -if (px < 0 || py < 0 || px >= this.canvas.width || py >= this.canvas.height) return null; -const buf = new Uint8Array(4); -gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFbo); -gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); -gl.bindFramebuffer(gl.FRAMEBUFFER, null); -const id = buf[0] + buf[1] * 0x100 + buf[2] * 0x10000 + buf[3] * 0x1000000; -if (id === 0) return null; -const g = this.gpuTraces.find( -(t) => t.pickBase > 0 && id >= t.pickBase && id < t.pickBase + t.pickCount -); -if (!g) return null; -return { trace: g.trace.id, index: id - g.pickBase, g }; -} -_decodeValue(values, meta, index) { -if (!values || !meta || index < 0 || index >= values.length) return NaN; -return values[index] / (meta.scale || 1) + meta.offset; -} -_dataFromCanvas(cssX, cssY, xAxisId = "x", yAxisId = "y") { -const [x0, x1] = this._axisRange(xAxisId); -const [y0, y1] = this._axisRange(yAxisId); -const xAxis = this._axis(xAxisId); -const yAxis = this._axis(yAxisId); -const cx0 = this._axisCoord(xAxis, x0); -const cx1 = this._axisCoord(xAxis, x1); -const cy0 = this._axisCoord(yAxis, y0); -const cy1 = this._axisCoord(yAxis, y1); -if (![cx0, cx1, cy0, cy1].every(Number.isFinite)) return [NaN, NaN]; -return [ -this._axisValue(xAxis, cx0 + (cssX / this.plot.w) * (cx1 - cx0)), -this._axisValue(yAxis, cy1 - (cssY / this.plot.h) * (cy1 - cy0)), -]; -} -_nearestCpuIndex(g, dataX) { -const cpu = g && g._cpu; -if (!cpu || !cpu.x || !cpu.x.length) return -1; -const xMeta = cpu.xMeta || g.xMeta; -const axis = this._axis(g.xAxis); -const target = this._axisCoord(axis, dataX); -let best = -1; -let bestDist = Infinity; -const limit = Math.min(cpu.x.length, g.n || cpu.x.length); -for (let i = 0; i < limit; i++) { -const x = this._decodeValue(cpu.x, xMeta, i); -const d = Math.abs(this._axisCoord(axis, x) - target); -if (d < bestDist) { -bestDist = d; -best = i; -} -} -return best; -} -_hoverAt(cssX, cssY) { -const maxPx = 12; -let best = null; -for (const g of this.gpuTraces) { -if (g.tier === "density") continue; -const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); -if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) continue; -if (g.heatmap && g._cpuHeatmap) { -const hit = this._heatmapHover(g, dataX, dataY); -if (hit) return hit; -continue; -} -if (g.trace.bar && g._cpu) { -const hit = this._barHover(g, dataX, dataY); -if (hit) return hit; -continue; -} -if (g._cpuRect) { -const hit = this._rectHover(g, dataX, dataY); -if (hit) return hit; -continue; -} -if (!g._cpu || !g._cpu.x || !g._cpu.y) continue; -const idx = this._nearestCpuIndex(g, dataX); -if (idx < 0) continue; -const x = this._decodeValue(g._cpu.x, g._cpu.xMeta, idx); -const y = this._decodeValue(g._cpu.y, g._cpu.yMeta, idx); -const px = this._dataPx(g.xAxis, x) - this.plot.x; -const py = this._dataPx(g.yAxis, y) - this.plot.y; -const dist = Math.hypot(px - cssX, py - cssY); -if (dist <= maxPx && (!best || dist < best.dist)) { -best = { trace: g.trace.id, index: idx, g, dist, synthetic: true }; -} -} -return best; -} -_barHover(g, dataX, dataY) { -const cpu = g._cpu; -const horizontal = g.orientation === 1; -const limit = Math.min(cpu.x.length, cpu.y.length, g.n || cpu.x.length); -for (let i = 0; i < limit; i++) { -const x = this._decodeValue(cpu.x, cpu.xMeta, i); -const y = this._decodeValue(cpu.y, cpu.yMeta, i); -const value0 = g.value0Mode === 1 && cpu.value0 -? this._decodeValue(cpu.value0, horizontal ? g.value0Meta : g.value0Meta, i) -: g.value0Const; -const lo = Math.min(value0 ?? 0, horizontal ? x : y); -const hi = Math.max(value0 ?? 0, horizontal ? x : y); -if (horizontal) { -if (dataX >= lo && dataX <= hi && Math.abs(dataY - y) <= g.width / 2) { -return { trace: g.trace.id, index: i, g, synthetic: true }; -} -} else if (Math.abs(dataX - x) <= g.width / 2 && dataY >= lo && dataY <= hi) { -return { trace: g.trace.id, index: i, g, synthetic: true }; -} -} -return null; -} -_rectHover(g, dataX, dataY) { -const r = g._cpuRect; -const limit = Math.min(r.x0.length, r.x1.length, r.y0.length, r.y1.length, g.n || r.x0.length); -for (let i = 0; i < limit; i++) { -const x0 = this._decodeValue(r.x0, r.x0Meta, i); -const x1 = this._decodeValue(r.x1, r.x1Meta, i); -const y0 = this._decodeValue(r.y0, r.y0Meta, i); -const y1 = this._decodeValue(r.y1, r.y1Meta, i); -if ( -dataX >= Math.min(x0, x1) && dataX <= Math.max(x0, x1) && -dataY >= Math.min(y0, y1) && dataY <= Math.max(y0, y1) -) { -return { trace: g.trace.id, index: i, g, synthetic: true }; -} -} -return null; -} -_heatmapHover(g, dataX, dataY) { -const h = g.heatmap; -if (!h || !g._cpuHeatmap) return null; -const [x0, x1] = h.xRange; -const [y0, y1] = h.yRange; -if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; -const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; -const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; -const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); -const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); -const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); -const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); -return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; -} -_drawKeepPick() { -this.draw(true); -} -_hover(e) { -this._a11yKeyboardReadout = null; -if (this._transitionActive()) { -const hadHover = this._hoverId !== -1; -this._hoverId = -1; -this._hoverTarget = null; -this._lastHoverXY = null; -this._pickSeq = (this._pickSeq || 0) + 1; -this.tooltip.style.display = "none"; -if (hadHover) this.draw(); -return; -} -const rect = this.canvas.getBoundingClientRect(); -const cssX = e.clientX - rect.left; -const cssY = e.clientY - rect.top; -const hit = this._pickAt(cssX, cssY) || this._hoverAt(cssX, cssY); -if (!hit) { -const hadHover = this._hoverId !== -1; -this._hoverId = -1; -this._hoverTarget = null; -this._lastHoverXY = null; -this._pickSeq = (this._pickSeq || 0) + 1; -this.tooltip.style.display = "none"; -if (hadHover) this._drawKeepPick(); -return; -} -const id = hit.trace * 1e9 + hit.index; -this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY }; -if (id === this._hoverId) { -this._renderTooltip(this._lastRow, e.clientX, e.clientY); -return; -} -this._hoverId = id; -this._hoverTarget = hit; -this._showTooltip(hit, e.clientX, e.clientY); -this._drawKeepPick(); -} -_asF32(b) { -if (b instanceof ArrayBuffer) return new Float32Array(b); -if (b.byteOffset % 4 === 0) { -return new Float32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); -} -return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); -} -_asU8(b) { -if (b instanceof ArrayBuffer) return new Uint8Array(b); -return new Uint8Array(b.buffer, b.byteOffset, b.byteLength); -} -_asU32(b) { -if (b instanceof ArrayBuffer) return new Uint32Array(b); -if (b.byteOffset % 4 === 0) { -return new Uint32Array(b.buffer, b.byteOffset, Math.floor(b.byteLength / 4)); -} -return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); -} -refreshTheme() { -if (this._destroyed) return; -this.theme = readTheme(this.root); -for (const g of this.gpuTraces) { -markOf(g.trace.kind).refreshColor?.(this, g); -} -this.draw(); -} -destroy() { -if (this._destroyed) return; -this._destroyed = true; -FC_CONTEXT_GOVERNOR.unregister(this); -this._ctxIo?.disconnect(); -this._ctxIo = null; -clearTimeout(this._rebinTimer); -if (this._rebinWorker) { -this._rebinWorker.terminate(); -if (this._rebinWorker._fcUrl) URL.revokeObjectURL(this._rebinWorker._fcUrl); -this._rebinWorker = null; -} -this._ro?.disconnect(); -this._io?.disconnect(); -this._io = null; -this._themeWatch?.removeEventListener?.("change", this._onScheme); -this._dprMq?.removeEventListener?.("change", this._onDprChange); -this._dprMq = null; -this._unsubscribeComm?.(); -this._unsubscribeComm = null; -for (const { target, type, handler, options } of this._listeners.splice(0)) { -target.removeEventListener(type, handler, options); -} -clearTimeout(this._viewTimer); -this._viewTimer = null; -if (this._viewEventRaf) cancelAnimationFrame(this._viewEventRaf); -this._viewEventRaf = null; -if (this._wheelZoomRaf) cancelAnimationFrame(this._wheelZoomRaf); -this._wheelZoomRaf = null; -this._pendingWheelZoom = null; -this._linkChannel?.close?.(); -this._linkChannel = null; -if (this._raf) cancelAnimationFrame(this._raf); -this._raf = null; -this._cancelViewAnimation(); -this._destroyGlResources(); -this.gl = null; -this.root.remove(); -} -_deleteBuffers(obj, names) { -const gl = this.gl; -if (!gl || !obj) return; -const seen = new Set(); -for (const name of names) { -const buf = obj[name]; -if (buf && !seen.has(buf)) { -seen.add(buf); -gl.deleteBuffer(buf); -} -obj[name] = null; -} -} -_destroyTraceResources(g, texSeen) { -if (!g) return; -this._destroyDensitySample(g); -this._deleteVaos(g); -this._deleteVaos(g.drill); -this._deleteBuffers(g, [ -"xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", -"x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", -"posBuf", "value1Buf", "value0Buf", -]); -this._deleteBuffers(g.drill, ["xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "dBuf"]); -const textures = []; -if (g.heatmap) textures.push(g.heatmap.tex); -for (const d of g.densityCache || []) textures.push(d && d.tex); -if (g.density) textures.push(g.density.tex); -if (g._shownDensity) textures.push(g._shownDensity.tex); -for (const tex of textures) { -if (tex && !texSeen.has(tex)) { -texSeen.add(tex); -this.gl.deleteTexture(tex); -} -} -g.drill = null; -g.density = null; -g._shownDensity = null; -g.densityCache = []; -g.heatmap = null; -g._cpu = null; -} -_destroyGlResources() { -const gl = this.gl; -if (!gl) return; -const texSeen = new Set(); -for (const g of this.gpuTraces || []) this._destroyTraceResources(g, texSeen); -for (const tex of this._lutCache.values()) { -if (tex && !texSeen.has(tex)) { -texSeen.add(tex); -gl.deleteTexture(tex); -} -} -this._lutCache.clear(); -if (this.pickFbo) gl.deleteFramebuffer(this.pickFbo); -if (this.pickTex && !texSeen.has(this.pickTex)) gl.deleteTexture(this.pickTex); -this.pickFbo = null; -this.pickTex = null; -if (this.quad) gl.deleteBuffer(this.quad); -this.quad = null; -if (this.quadVao) gl.deleteVertexArray(this.quadVao); -this.quadVao = null; -for (const p of this._progCache ? this._progCache.values() : []) { -if (p) gl.deleteProgram(p); -} -if (this._progCache) this._progCache.clear(); -this._glPrograms = this._progCache; -this.gpuTraces = []; -} -} -const FC_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ -"color", -"label_color", -"width", -"head_size", -"head_style", -"tail_style", -"shaft_width_start", -"shaft_width_end", -"curve", -"angle_a", -"angle_b", -"gap_start", -"gap_end", -"start_offset", -"label_clear", -"dash", -"span_start", -"span_end", -"size", -"symbol", -"stroke_color", -"stroke_width", -"coordinate_space", -]); -function fcLabelClearExit(style, tangent) { -if (typeof style.label_clear !== "string") return 0; -const parts = style.label_clear.split(",").map(Number); -if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0)) return 0; -const [left, right, up, down] = parts; -const [tx, ty] = tangent; -const exitX = tx > 1e-9 ? right / tx : tx < -1e-9 ? left / -tx : Infinity; -const exitY = ty > 1e-9 ? down / ty : ty < -1e-9 ? up / -ty : Infinity; -const exit = Math.min(exitX, exitY); -return Number.isFinite(exit) ? exit : 0; -} -function fcArrowGeometry(x0, y0, x1, y1, style) { -const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); -if (typeof style.start_offset === "string") { -const offset = style.start_offset.split(",").map(Number); -if (offset.length === 2 && offset.every(Number.isFinite)) { -x0 += offset[0]; -y0 += offset[1]; -} -} -const angleA = num(style.angle_a); -const angleB = num(style.angle_b); -const curve = num(style.curve); -let cx = null; -let cy = null; -if (angleA !== null && angleB !== null) { -const a = (-angleA * Math.PI) / 180; -const b = (-angleB * Math.PI) / 180; -const denom = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b); -if (Math.abs(denom) > 1e-6) { -const t = ((x1 - x0) * Math.sin(b) - (y1 - y0) * Math.cos(b)) / denom; -cx = x0 + t * Math.cos(a); -cy = y0 + t * Math.sin(a); -} -} else if (curve) { -const dx = x1 - x0; -const dy = y1 - y0; -cx = (x0 + x1) / 2 + curve * dy; -cy = (y0 + y1) / 2 - curve * dx; -} -const toward = (px, py, qx, qy) => { -const d = Math.hypot(qx - px, qy - py) || 1; -return [(qx - px) / d, (qy - py) / d]; -}; -const t0 = cx === null ? toward(x0, y0, x1, y1) : toward(x0, y0, cx, cy); -const t1 = cx === null ? toward(x1, y1, x0, y0) : toward(x1, y1, cx, cy); -const gapStart = Math.max(0, num(style.gap_start) || 0, fcLabelClearExit(style, t0)); -const gapEnd = Math.max(0, num(style.gap_end) || 0); -const span = Math.hypot(x1 - x0, y1 - y0); -const trim = gapStart + gapEnd < span * 0.9; -const p0 = trim ? [x0 + gapStart * t0[0], y0 + gapStart * t0[1]] : [x0, y0]; -const p1 = trim ? [x1 + gapEnd * t1[0], y1 + gapEnd * t1[1]] : [x1, y1]; -const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); -const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); -return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; -} -function fcArrowShaftPoints(geom, samples = 24) { -const [x0, y0] = geom.p0; -const [x1, y1] = geom.p1; -if (!geom.control) return [[x0, y0], [x1, y1]]; -const [cx, cy] = geom.control; -const points = []; -for (let i = 0; i <= samples; i++) { -const t = i / samples; -const u = 1 - t; -points.push([u * u * x0 + 2 * u * t * cx + t * t * x1, u * u * y0 + 2 * u * t * cy + t * t * y1]); -} -return points; -} -function fcTrimPolylineEnd(points, trim) { -if (!(trim > 0) || points.length < 2) return points; -const out = points.slice(); -let remaining = trim; -while (out.length >= 2) { -const [ax, ay] = out[out.length - 2]; -const [bx, by] = out[out.length - 1]; -const seg = Math.hypot(bx - ax, by - ay); -if (seg > remaining) { -const t = 1 - remaining / seg; -out[out.length - 1] = [ax + t * (bx - ax), ay + t * (by - ay)]; -return out; -} -remaining -= seg; -out.pop(); -} -return out; -} -function fcTaperPolygon(points, w0, w1) { -const left = []; -const right = []; -const count = points.length; -for (let i = 0; i < count; i++) { -const [px, py] = points[i]; -const [ax, ay] = points[Math.max(0, i - 1)]; -const [bx, by] = points[Math.min(count - 1, i + 1)]; -const d = Math.hypot(bx - ax, by - ay) || 1; -const nx = -(by - ay) / d; -const ny = (bx - ax) / d; -const half = (w0 + (w1 - w0) * (i / Math.max(1, count - 1))) / 2; -left.push([px + half * nx, py + half * ny]); -right.push([px - half * nx, py - half * ny]); -} -return left.concat(right.reverse()); -} -Object.assign(ChartView.prototype, { -_annotationPaint(style, fallback) { -return safeCssPaint(this.root, style && style.color, fallback); -}, -_annotationLabelPaint(style, fallback) { -return safeCssPaint(this.root, style && (style.label_color || style.color), fallback); -}, -_annotationStrokePaint(style, fallback) { -return safeCssPaint(this.root, style && style.stroke_color, fallback); -}, -_drawAnnotationMarker(ctx, x, y, style, ann) { -if (!Number.isFinite(x) || !Number.isFinite(y)) return; -const r = Math.max(1, this._styleNumber(style, "size", Number(ann.size) || 8) / 2); -const symbol = ["circle", "square", "diamond", "cross"].includes(ann.symbol) ? ann.symbol : "circle"; -ctx.save(); -ctx.globalAlpha = this._styleNumber(style, "opacity", 1); -ctx.fillStyle = this._annotationPaint(style, [0.15, 0.39, 0.92, 1]); -ctx.strokeStyle = symbol === "cross" -? this._annotationPaint(style, [0.15, 0.39, 0.92, 1]) -: this._annotationStrokePaint(style, [1, 1, 1, 1]); -ctx.lineWidth = Math.max(0, this._styleNumber(style, "stroke_width", 1.5)); -ctx.beginPath(); -if (symbol === "square") { -ctx.rect(x - r, y - r, r * 2, r * 2); -} else if (symbol === "diamond") { -ctx.moveTo(x, y - r); -ctx.lineTo(x + r, y); -ctx.lineTo(x, y + r); -ctx.lineTo(x - r, y); -ctx.closePath(); -} else if (symbol === "cross") { -ctx.moveTo(x - r, y); -ctx.lineTo(x + r, y); -ctx.moveTo(x, y - r); -ctx.lineTo(x, y + r); -ctx.stroke(); -ctx.restore(); -return; -} else { -ctx.arc(x, y, r, 0, Math.PI * 2); -} -ctx.fill(); -if (ctx.lineWidth > 0) ctx.stroke(); -ctx.restore(); -}, -_drawArrowLine(ctx, x0, y0, x1, y1, style) { -if (![x0, y0, x1, y1].every(Number.isFinite)) return; -const geom = fcArrowGeometry(x0, y0, x1, y1, style); -ctx.save(); -ctx.globalAlpha = this._styleNumber(style, "opacity", 1); -ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); -ctx.fillStyle = ctx.strokeStyle; -ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); -ctx.setLineDash(Array.isArray(style.dash) ? style.dash : -(typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); -const w0 = Number(style.shaft_width_start); -const w1 = Number(style.shaft_width_end); -const headStyle = style.head_style || "triangle"; -const head = Math.max(4, this._styleNumber(style, "head_size", 8)); -if (Number.isFinite(w0) || Number.isFinite(w1)) { -let points = fcArrowShaftPoints(geom); -if (headStyle === "triangle") { -points = fcTrimPolylineEnd(points, head * Math.cos(Math.PI / 6)); -} -const polygon = fcTaperPolygon( -points, -Number.isFinite(w0) ? w0 : 1, -Number.isFinite(w1) ? w1 : 1 -); -ctx.beginPath(); -ctx.moveTo(polygon[0][0], polygon[0][1]); -for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i][0], polygon[i][1]); -ctx.closePath(); -ctx.fill(); -} else { -ctx.beginPath(); -ctx.moveTo(geom.p0[0], geom.p0[1]); -if (geom.control) ctx.quadraticCurveTo(geom.control[0], geom.control[1], geom.p1[0], geom.p1[1]); -else ctx.lineTo(geom.p1[0], geom.p1[1]); -ctx.stroke(); -} -this._drawArrowEnd(ctx, geom.p1, geom.dir1, headStyle, head); -this._drawArrowEnd(ctx, geom.p0, geom.dir0, style.tail_style || "none", head); -ctx.restore(); -}, -_drawArrowEnd(ctx, point, dir, endStyle, head) { -if (endStyle === "none") return; -const [px, py] = point; -const angle = Math.atan2(dir[1], dir[0]); -ctx.beginPath(); -if (endStyle === "bar") { -ctx.moveTo(px - (head / 2) * Math.sin(angle), py + (head / 2) * Math.cos(angle)); -ctx.lineTo(px + (head / 2) * Math.sin(angle), py - (head / 2) * Math.cos(angle)); -ctx.stroke(); -return; -} -const wing = (side) => [ -px - head * Math.cos(angle - side * Math.PI / 6), -py - head * Math.sin(angle - side * Math.PI / 6), -]; -const [ax, ay] = wing(1); -const [bx, by] = wing(-1); -if (endStyle === "v") { -ctx.moveTo(ax, ay); -ctx.lineTo(px, py); -ctx.lineTo(bx, by); -ctx.stroke(); -return; -} -ctx.moveTo(px, py); -ctx.lineTo(ax, ay); -ctx.lineTo(bx, by); -ctx.closePath(); -ctx.fill(); -}, -_drawAnnotationShapes(ctx) { -const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; -if (!annotations.length) return; -const p = this.plot; -ctx.save(); -ctx.beginPath(); -ctx.rect(p.x, p.y, p.w, p.h); -ctx.clip(); -for (const ann of annotations) { -const style = ann && typeof ann.style === "object" ? ann.style : {}; -if (ann.kind === "band") { -const vertical = ann.axis === "x"; -const a = vertical ? this._dataPxX(Number(ann.start)) : this._dataPxY(Number(ann.start)); -const b = vertical ? this._dataPxX(Number(ann.end)) : this._dataPxY(Number(ann.end)); -if (!Number.isFinite(a) || !Number.isFinite(b)) continue; -const lo = Math.max(vertical ? p.x : p.y, Math.min(a, b)); -const hi = Math.min(vertical ? p.x + p.w : p.y + p.h, Math.max(a, b)); -if (hi <= lo) continue; -ctx.save(); -ctx.globalAlpha = this._styleNumber(style, "opacity", 0.14); -ctx.fillStyle = this._annotationPaint(style, [0.39, 0.45, 0.55, 1]); -const start = Math.max(0, Math.min(1, Number(style.span_start) || 0)); -const rawEnd = style.span_end === undefined ? 1 : Number(style.span_end); -const end = Math.max(start, Math.min(1, Number.isFinite(rawEnd) ? rawEnd : 1)); -if (vertical) ctx.fillRect(lo, p.y + (1 - end) * p.h, hi - lo, (end - start) * p.h); -else ctx.fillRect(p.x + start * p.w, lo, (end - start) * p.w, hi - lo); -ctx.restore(); -} else if (ann.kind === "rule") { -const vertical = ann.axis === "x"; -const pos = vertical ? this._dataPxX(Number(ann.value)) : this._dataPxY(Number(ann.value)); -if (!Number.isFinite(pos)) continue; -if (vertical && (pos < p.x - 1 || pos > p.x + p.w + 1)) continue; -if (!vertical && (pos < p.y - 1 || pos > p.y + p.h + 1)) continue; -const crisp = Math.round(pos) + 0.5; -ctx.save(); -ctx.globalAlpha = this._styleNumber(style, "opacity", 1); -ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); -ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); -ctx.setLineDash(Array.isArray(style.dash) ? style.dash : -(typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); -ctx.beginPath(); -const start = Math.max(0, Math.min(1, Number(style.span_start) || 0)); -const rawEnd = style.span_end === undefined ? 1 : Number(style.span_end); -const end = Math.max(start, Math.min(1, Number.isFinite(rawEnd) ? rawEnd : 1)); -if (vertical) { -ctx.moveTo(crisp, p.y + (1 - end) * p.h); -ctx.lineTo(crisp, p.y + (1 - start) * p.h); -} else { -ctx.moveTo(p.x + start * p.w, crisp); -ctx.lineTo(p.x + end * p.w, crisp); -} -ctx.stroke(); -ctx.restore(); -} else if (ann.kind === "arrow") { -this._drawArrowLine( -ctx, -this._dataPxX(Number(ann.x0)), -this._dataPxY(Number(ann.y0)), -this._dataPxX(Number(ann.x1)), -this._dataPxY(Number(ann.y1)), -style -); -} else if (ann.kind === "callout") { -const px = this._dataPxX(Number(ann.x)); -const py = this._dataPxY(Number(ann.y)); -const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; -const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; -this._drawArrowLine(ctx, px + dx, py + dy, px, py, style); -} else if (ann.kind === "marker") { -this._drawAnnotationMarker( -ctx, -this._dataPxX(Number(ann.x)), -this._dataPxY(Number(ann.y)), -style, -ann -); -} -} -ctx.restore(); -}, -_drawAnnotationLabels(updateLabels) { -if (!updateLabels) return; -const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; -if (!annotations.length) return; -const p = this.plot; -for (const ann of annotations) { -const text = typeof ann.text === "string" ? ann.text : ""; -if (!text) continue; -const style = ann && typeof ann.style === "object" ? ann.style : {}; -let px = null; -let py = null; -if (ann.kind === "text") { -if (style.coordinate_space === "axes_fraction") { -px = p.x + Number(ann.x) * p.w; -py = p.y + (1 - Number(ann.y)) * p.h; -} else if (style.coordinate_space === "figure_fraction") { -px = Number(ann.x) * this.size.w; -py = (1 - Number(ann.y)) * this.size.h; -} else if (style.coordinate_space === "yaxis_transform") { -px = p.x + Number(ann.x) * p.w; -py = this._dataPxY(Number(ann.y)); -} else if (style.coordinate_space === "xaxis_transform") { -px = this._dataPxX(Number(ann.x)); -py = p.y + (1 - Number(ann.y)) * p.h; -} else { -px = this._dataPxX(Number(ann.x)); -py = this._dataPxY(Number(ann.y)); -} -} else if (ann.kind === "rule") { -if (ann.axis === "x") { -px = this._dataPxX(Number(ann.value)); -py = p.y + 6; -} else { -px = p.x + p.w - 6; -py = this._dataPxY(Number(ann.value)); -} -} else if (ann.kind === "band") { -if (ann.axis === "x") { -px = (this._dataPxX(Number(ann.start)) + this._dataPxX(Number(ann.end))) / 2; -py = p.y + 6; -} else { -px = p.x + p.w - 6; -py = (this._dataPxY(Number(ann.start)) + this._dataPxY(Number(ann.end))) / 2; -} -} else if (ann.kind === "arrow") { -px = (this._dataPxX(Number(ann.x0)) + this._dataPxX(Number(ann.x1))) / 2; -py = (this._dataPxY(Number(ann.y0)) + this._dataPxY(Number(ann.y1))) / 2; -} else if (ann.kind === "callout") { -px = this._dataPxX(Number(ann.x)); -py = this._dataPxY(Number(ann.y)); -} else if (ann.kind === "marker") { -px = this._dataPxX(Number(ann.x)); -py = this._dataPxY(Number(ann.y)); -} -if (!Number.isFinite(px) || !Number.isFinite(py)) continue; -if (px < p.x - 24 || px > p.x + p.w + 24 || py < p.y - 24 || py > p.y + p.h + 24) { -continue; -} -const d = document.createElement("div"); -d.textContent = text; -const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; -const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; -const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0px"; -const rot = Number.isFinite(Number(style.rotation)) -? ((Number(style.rotation) % 360) + 360) % 360 -: 0; -const va = String(style.vertical_align || ""); -const vAnchor = -va === "center" || va === "middle" ? "-50%" -: va === "bottom" ? "-100%" -: va === "top" ? "0px" -: "calc(-100% + 0.35em)"; -let transform = `translate(${anchor},${vAnchor})`; -if (rot === 90 || rot === 270) { -const cw = rot === 270; -const along = -va === "center" || va === "middle" ? "-50%" -: va === "top" ? (cw ? "0" : "-100%") -: va === "bottom" ? (cw ? "-100%" : "0") -: cw ? "0" : "-100%"; -const cross = -ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? (cw ? "0" : "-100%") : cw ? "-100%" : "0"; -transform = `rotate(${cw ? 90 : -90}deg) translate(${along},${cross})`; -} else if (rot) { -transform = `rotate(${-rot}deg) translate(${anchor},${vAnchor})`; -} -d.style.cssText = -`position:absolute;left:${px + dx}px;top:${py + dy}px;` + -`transform:${transform};transform-origin:0 0;pointer-events:none;` + -`white-space:pre-line;text-align:center;width:max-content;`; -this._applySlot(d, "annotation_label"); -this._applyClass(d, ann.class_name); -const labelStyle = {}; -for (const [key, value] of Object.entries(style)) { -if (FC_ANNOTATION_SHAPE_STYLE_KEYS.has(key)) continue; -labelStyle[key] = value; -} -this._applyStyle(d, labelStyle); -if (style && (style.label_color || style.color)) { -d.style.color = this._annotationLabelPaint(style, this.theme.label); -} -this.labels.appendChild(d); -const cs = getComputedStyle(d); -const edge = (pad, border) => (parseFloat(pad) || 0) + (parseFloat(border) || 0); -const padL = edge(cs.paddingLeft, cs.borderLeftWidth); -const padR = edge(cs.paddingRight, cs.borderRightWidth); -const padT = edge(cs.paddingTop, cs.borderTopWidth); -const padB = edge(cs.paddingBottom, cs.borderBottomWidth); -if ((padL || padR || padT || padB) && rot !== 90 && rot !== 270) { -const hShift = anchor === "-100%" ? padR : anchor === "-50%" ? 0 : -padL; -const vShift = -vAnchor === "-50%" ? 0 : vAnchor === "0px" ? -padT : padB; -d.style.transform = -`${rot ? `rotate(${-rot}deg) ` : ""}` + -`translate(calc(${anchor} + ${hShift}px), calc(${vAnchor} + ${vShift}px))`; -} -} -}, -}); -Object.assign(ChartView.prototype, { -_showTooltip(hit, clientX, clientY) { -const row = this._localRow(hit); -this._lastRow = row; -this._renderTooltip(row, clientX, clientY); -if (this._interactionFlag("hover")) { -this._dispatchChartEvent("hover", { -row, -trace: hit.trace, -index: hit.index, -view: this._eventView("hover"), -}); -} -if (this.comm) { -this._pickSeq = (this._pickSeq || 0) + 1; -const req = { type: "pick", seq: this._pickSeq, trace: hit.trace, index: hit.index }; -const hg = hit.g; -if (hg && hg.tier === "density" && hg.drill && hg.drill.seq !== undefined) { -req.drill_seq = hg.drill.seq; -} -this.comm.send(req); -} -}, -_localRow(hit) { -const g = hit.g; -const cpu = g._cpu; -const row = { trace: g.trace.id, index: hit.index }; -if (hit.heatmap && g.heatmap && g._cpuHeatmap) { -const h = g.heatmap; -const { row: heatRow, col } = hit.heatmap; -const rawX = h.xRange[0] + (col + 0.5) * ((h.xRange[1] - h.xRange[0]) / h.w); -const rawY = h.yRange[0] + (heatRow + 0.5) * ((h.yRange[1] - h.yRange[0]) / h.h); -const [x, xKind] = this._sourceDisplayValue(g, "x", rawX, "float"); -const [y, yKind] = this._sourceDisplayValue(g, "y", rawY, "float"); -row.x = x; -row.y = y; -if (xKind !== undefined) row.x_kind = xKind; -if (yKind !== undefined) row.y_kind = yKind; -const norm = g._cpuHeatmap.grid[hit.index]; -row.color_value = this._denormalizeUnit(norm, g.trace.color && g.trace.color.domain); -} else if (g._cpuRect) { -const r = g._cpuRect; -const x0 = this._decodeValue(r.x0, r.x0Meta, hit.index); -const x1 = this._decodeValue(r.x1, r.x1Meta, hit.index); -const y0 = this._decodeValue(r.y0, r.y0Meta, hit.index); -const y1 = this._decodeValue(r.y1, r.y1Meta, hit.index); -row.x = x0 + (x1 - x0) / 2; -row.y = y1; -row.x_kind = r.x0Meta.kind; -row.y_kind = r.y1Meta.kind; -} else if (cpu) { -const xMeta = cpu.xMeta || g.xMeta; -const yMeta = cpu.yMeta || g.yMeta; -row.x = this._decodeValue(cpu.x, xMeta, hit.index); -row.y = this._decodeValue(cpu.y, yMeta, hit.index); -row.x_kind = xMeta && xMeta.kind; -row.y_kind = yMeta && yMeta.kind; -const color = g.trace.color; -if (cpu.color && color) { -if (color.mode === "categorical" && Array.isArray(color.categories)) { -const code = Math.round(cpu.color[hit.index]); -if (code >= 0 && code < color.categories.length) { -row.color_category = String(color.categories[code]); -} -} else if (color.mode === "continuous") { -row.color_value = this._denormalizeUnit(cpu.color[hit.index], color.domain); -} -} -const size = g.trace.size; -if (cpu.size && size && size.mode === "continuous") { -row.size_value = this._denormalizeUnit(cpu.size[hit.index], size.domain); -} -} -this._applySharedTooltipFields(row); -return row; -}, -_sourceDisplayValue(g, channel, value, kind) { -const axis = channel === "x" ? this._axis(g && g.xAxis) : this._axis(g && g.yAxis); -if (channel === "x" && axis.kind === "category") { -return [fmtCategory(value, axis.categories || []), undefined]; -} -if (channel === "y" && axis.kind === "category") { -return [fmtCategory(value, axis.categories || []), undefined]; -} -return [value, kind]; -}, -_sourceValue(g, source, index) { -if (!g || index < 0) return [undefined, undefined]; -const channel = source.channel; -if (channel === "x" || channel === "y") { -const cpu = g._cpu; -if (!cpu || !cpu[channel]) return [undefined, undefined]; -const meta = channel === "x" ? (cpu.xMeta || g.xMeta) : (cpu.yMeta || g.yMeta); -const value = this._decodeValue(cpu[channel], meta, index); -if (!Number.isFinite(value)) return [undefined, undefined]; -return this._sourceDisplayValue(g, channel, value, meta && meta.kind); -} -if (channel === "color_value") { -if (g._cpuHeatmap && g._cpuHeatmap.grid && g.trace.color) { -return [this._denormalizeUnit(g._cpuHeatmap.grid[index], g.trace.color.domain), undefined]; -} -if (g._cpu && g._cpu.color && g.trace.color) { -return [this._denormalizeUnit(g._cpu.color[index], g.trace.color.domain), undefined]; -} -} -if (channel === "color_category" && g._cpu && g._cpu.color && g.trace.color) { -const code = Math.round(g._cpu.color[index]); -const categories = g.trace.color.categories || []; -if (code >= 0 && code < categories.length) return [String(categories[code]), undefined]; -} -if (channel === "size_value" && g._cpu && g._cpu.size && g.trace.size) { -return [this._denormalizeUnit(g._cpu.size[index], g.trace.size.domain), undefined]; -} -return [undefined, undefined]; -}, -_applySharedTooltipFields(row) { -const sources = this.spec.tooltip && this.spec.tooltip.sources; -if (!sources || typeof sources !== "object" || row.x === undefined) return; -for (const [field, entries] of Object.entries(sources)) { -if (!Array.isArray(entries) || row[field] !== undefined) continue; -const source = entries.find((entry) => entry.trace === row.trace) || entries[0]; -if (!source || !Number.isFinite(Number(source.trace))) continue; -const g = this.gpuTraces.find((trace) => trace.trace.id === source.trace); -if (!g) continue; -let idx = Number.isInteger(row.index) && source.trace === row.trace ? row.index : -1; -if ( -!g._cpuHeatmap && -(idx < 0 || !g._cpu || !g._cpu.x || idx >= g._cpu.x.length) -) { -idx = this._nearestCpuIndex(g, row.x); -} -const [value, kind] = this._sourceValue(g, source, idx); -if (value === undefined) continue; -row[field] = value; -if (kind !== undefined) row[`${field}_kind`] = kind; -} -}, -_denormalizeUnit(value, domain) { -const v = Number(value); -if (!Number.isFinite(v)) return v; -if (!Array.isArray(domain) || domain.length < 2) return v; -const lo = Number(domain[0]); -const hi = Number(domain[1]); -if (!Number.isFinite(lo) || !Number.isFinite(hi)) return v; -return lo + v * (hi - lo); -}, -_defaultTooltipLines(row) { -const lines = []; -if (row.x !== undefined) lines.push(`x: ${fmtValue(row.x, row.x_kind)}`); -if (row.y !== undefined) lines.push(`y: ${fmtValue(row.y, row.y_kind)}`); -if (row.color_value !== undefined) lines.push(`color: ${fmtValue(row.color_value)}`); -if (row.color_category !== undefined) lines.push(`${row.color_category}`); -if (row.size_value !== undefined) lines.push(`size: ${fmtValue(row.size_value)}`); -if (!lines.length) lines.push(`#${row.index}`); -return lines; -}, -_tooltipLookup(row, field) { -const aliases = (this.spec.tooltip && this.spec.tooltip.aliases) || {}; -const key = row[field] !== undefined ? field : aliases[field]; -if (!key || row[key] === undefined) return [undefined, undefined]; -return [row[key], row[`${key}_kind`]]; -}, -_formatTooltipValue(value, kind, format) { -const formatted = fmtNumberSpec(value, format); -if (formatted !== null) return formatted; -return fmtValue(value, kind); -}, -_tooltipLines(row) { -const tooltip = this.spec.tooltip || {}; -if (!tooltip.title && !Array.isArray(tooltip.fields)) return this._defaultTooltipLines(row); -const formats = tooltip.format || {}; -const lines = []; -if (typeof tooltip.title === "string") { -const title = tooltip.title.replace(/\{([^}]+)\}/g, (_, field) => { -const [value, kind] = this._tooltipLookup(row, field); -return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); -}); -if (title) lines.push(title); -} -if (Array.isArray(tooltip.fields)) { -for (const field of tooltip.fields) { -if (typeof field !== "string") continue; -const [value, kind] = this._tooltipLookup(row, field); -if (value === undefined) continue; -lines.push(`${field}: ${this._formatTooltipValue(value, kind, formats[field])}`); -} -} -return lines.length ? lines : this._defaultTooltipLines(row); -}, -_renderTooltip(row, clientX, clientY, options = {}) { -if (!row || this.spec.show_tooltip === false) { -this.tooltip.style.display = "none"; -return; -} -const rect = this.root.getBoundingClientRect(); -const lx = clientX - rect.left; -const ly = clientY - rect.top; -const lines = this._tooltipLines(row); -this.tooltip.textContent = ""; -lines.forEach((ln, i) => { -if (i) this.tooltip.appendChild(document.createElement("br")); -this.tooltip.appendChild(document.createTextNode(ln)); -}); -if (this.a11yLive && options.announce !== false) { -const prefix = this._a11yKeyboardReadout; -const detail = lines.join(", "); -const announcement = prefix -? `Point ${prefix.flat + 1} of ${prefix.total}. ${detail}` -: detail; -if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement; -} -this.tooltip.style.display = "block"; -const tw = this.tooltip.offsetWidth; -this.tooltip.style.left = Math.min(lx + 12, this.size.w - tw - 4) + "px"; -this.tooltip.style.top = ly + 12 + "px"; -}, -}); -Object.assign(ChartView.prototype, { -_initInteraction() { -const c = this.canvas; -let drag = null; -let band = null; -this.selRect = document.createElement("div"); -this.selRect.style.cssText = "position:absolute;display:none;pointer-events:none;z-index:4;"; -this._applySlot(this.selRect, "selection"); -this.root.appendChild(this.selRect); -this.selLasso = document.createElementNS("http://www.w3.org/2000/svg", "svg"); -this.selLasso.style.cssText = -"position:absolute;display:none;pointer-events:none;z-index:4;overflow:visible;"; -this.selLasso.dataset.fcSelectionLassoOverlay = ""; -this.selLassoPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); -this.selLassoPath.dataset.fcSelectionLasso = ""; -this.selLasso.appendChild(this.selLassoPath); -this.selLassoHandles = document.createElementNS("http://www.w3.org/2000/svg", "g"); -this.selLassoHandles.dataset.fcSelectionLassoHandles = ""; -this.selLasso.appendChild(this.selLassoHandles); -this.root.appendChild(this.selLasso); -this._lassoPolygon = null; -let lassoHandleDrag = null; -const moveLassoHandle = (e) => { -if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId -|| !this._lassoPolygon) return; -const rect = c.getBoundingClientRect(); -const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); -const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); -this._lassoPolygon[lassoHandleDrag.index] = this._dataFromCanvas(cssX, cssY); -this._renderLassoSelection(); -e.preventDefault(); -e.stopPropagation(); -}; -this._listen(this.selLasso, "pointerdown", (e) => { -const handle = e.target.closest?.("[data-fc-selection-lasso-handle]"); -if (!handle || !this._lassoPolygon) return; -const index = Number(handle.dataset.fcSelectionLassoHandle); -if (!Number.isInteger(index) || !this._lassoPolygon[index]) return; -lassoHandleDrag = { -index, -pointerId: e.pointerId, -original: [...this._lassoPolygon[index]], -handle, -}; -handle.dataset.fcActive = ""; -this.tooltip.style.display = "none"; -try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { } -e.preventDefault(); -e.stopPropagation(); -}); -this._listen(this.selLasso, "pointermove", moveLassoHandle); -this._listen(this.selLasso, "pointerup", (e) => { -if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; -moveLassoHandle(e); -const handle = lassoHandleDrag.handle; -lassoHandleDrag = null; -delete handle.dataset.fcActive; -if (this._lassoPolygon) this._sendSelectPolygon(this._lassoPolygon); -}); -this._listen(this.selLasso, "pointercancel", (e) => { -if (!lassoHandleDrag || e.pointerId !== lassoHandleDrag.pointerId) return; -if (this._lassoPolygon) { -this._lassoPolygon[lassoHandleDrag.index] = lassoHandleDrag.original; -} -delete lassoHandleDrag.handle.dataset.fcActive; -lassoHandleDrag = null; -if (this._lassoPolygon) this._renderLassoSelection(); -e.stopPropagation(); -}); -if (this._interactionFlag("crosshair")) { -this.crosshairX = document.createElement("div"); -this.crosshairX.style.cssText = -"position:absolute;display:none;pointer-events:none;z-index:3;width:1px;"; -this._applySlot(this.crosshairX, "crosshair_x"); -this.root.appendChild(this.crosshairX); -this.crosshairY = document.createElement("div"); -this.crosshairY.style.cssText = -"position:absolute;display:none;pointer-events:none;z-index:3;height:1px;"; -this._applySlot(this.crosshairY, "crosshair_y"); -this.root.appendChild(this.crosshairY); -} -const dataAt = (clientX, clientY) => { -const r = c.getBoundingClientRect(); -return this._dataFromCanvas(clientX - r.left, clientY - r.top); -}; -const lassoPointAt = (clientX, clientY) => { -const r = c.getBoundingClientRect(); -const cssX = Math.max(0, Math.min(r.width, clientX - r.left)); -const cssY = Math.max(0, Math.min(r.height, clientY - r.top)); -return { -x: r.left + cssX, -y: r.top + cssY, -data: this._dataFromCanvas(cssX, cssY), -}; -}; -this._listen(c, "pointerdown", (e) => { -this._cancelViewAnimation(); -const canBrush = this._interactionFlag("brush", true) && this._interactionFlag("select", true); -const selectMode = this.dragMode.startsWith("select") ? this.dragMode : null; -const mode = (e.shiftKey || selectMode) && canBrush && this._pickable -? (e.shiftKey ? "select" : selectMode) -: this.dragMode === "zoom" ? "zoom" : null; -if (mode) { -const previousLasso = mode.startsWith("select") && this._lassoPolygon -? this._lassoPolygon.map((point) => [...point]) -: null; -if (mode.startsWith("select")) this._clearLassoOverlay(); -const firstLassoPoint = mode === "select-lasso" ? lassoPointAt(e.clientX, e.clientY) : null; -const d0 = firstLassoPoint ? firstLassoPoint.data : dataAt(e.clientX, e.clientY); -band = { -mode, sx: e.clientX, sy: e.clientY, d0, -points: firstLassoPoint ? [firstLassoPoint] : null, -previousLasso, -}; -c.setPointerCapture(e.pointerId); -this.tooltip.style.display = "none"; -return; -} -drag = { px: e.clientX, py: e.clientY, view: { ...this.view }, moved: false }; -c.setPointerCapture(e.pointerId); -this.tooltip.style.display = "none"; -}); -this._listen(c, "pointermove", (e) => { -if (band) { this._updateBand(band, e); return; } -if (drag) { -drag.moved = true; -const { x0, x1, y0, y1 } = drag.view; -const xa = this._axis("x"); -const ya = this._axis("y"); -const cx0 = this._axisCoord(xa, x0), cx1 = this._axisCoord(xa, x1); -const cy0 = this._axisCoord(ya, y0), cy1 = this._axisCoord(ya, y1); -const dx = ((e.clientX - drag.px) / this.plot.w) * (cx1 - cx0); -const dy = ((e.clientY - drag.py) / this.plot.h) * (cy1 - cy0); -this.view = { -x0: this._axisValue(xa, cx0 - dx), -x1: this._axisValue(xa, cx1 - dx), -y0: this._axisValue(ya, cy0 + dy), -y1: this._axisValue(ya, cy1 + dy), -}; -this.draw(); -this._scheduleViewRequest(); -this._emitViewChange("pan"); -return; -} -this._updateCrosshair(e); -this._hover(e); -}); -const end = (e) => { -if (band) { -this.selRect.style.display = "none"; -this.selLasso.style.display = "none"; -const d1 = dataAt(e.clientX, e.clientY); -const moved = Math.abs(e.clientX - band.sx) > 3 || Math.abs(e.clientY - band.sy) > 3; -if (moved) { -if (band.mode === "zoom") this._zoomToBox(band.d0, d1, true); -else if (band.mode === "select-lasso") { -if (band.points.length >= 3) { -const editable = this._simplifyLassoPoints(band.points); -this._sendSelectPolygon(editable.map((point) => point.data)); -} else if (band.previousLasso) { -this._lassoPolygon = band.previousLasso; -this._renderLassoSelection(); -} -} else { -let d0 = band.d0; -if (band.mode === "select-x") { -d0 = [band.d0[0], this.view.y0]; -d1[1] = this.view.y1; -} else if (band.mode === "select-y") { -d0 = [this.view.x0, band.d0[1]]; -d1[0] = this.view.x1; -} -this._sendSelect(d0, d1); -} -this._ignoreNextClick = true; -} else if (band.previousLasso) { -this._lassoPolygon = band.previousLasso; -this._renderLassoSelection(); -} -band = null; -return; -} -if (drag && drag.moved) this._ignoreNextClick = true; -if (drag && !drag.moved) this.tooltip.style.display = "none"; -drag = null; -}; -this._listen(c, "pointerup", end); -this._listen(c, "pointercancel", () => { -this.selRect.style.display = "none"; -this.selLasso.style.display = "none"; -if (band?.previousLasso) { -this._lassoPolygon = band.previousLasso; -this._renderLassoSelection(); -} -band = null; -drag = null; -}); -this._listen(c, "pointerleave", () => { -const hadHover = this._hoverId !== -1; -this._hoverId = -1; -this._hoverTarget = null; -this._lastHoverXY = null; -this._a11yKeyboardReadout = null; -this._pickSeq = (this._pickSeq || 0) + 1; -this.tooltip.style.display = "none"; -this._hideCrosshair(); -if (this._interactionFlag("hover")) { -this._dispatchChartEvent("leave", { view: this._eventView("leave") }); -} -if (hadHover) this._drawKeepPick(); -}); -this._listen(c, "click", (e) => this._click(e)); -this._listen(c, "wheel", (e) => { -e.preventDefault(); -const f = Math.pow(1.0015, e.deltaY); -const r = c.getBoundingClientRect(); -const fx = (e.clientX - r.left) / r.width; -const fy = 1 - (e.clientY - r.top) / r.height; -this._queueWheelZoom(f, fx, fy); -}, { passive: false }); -this._listen(c, "dblclick", () => { -this._clearSelection(); -this._setView(this.view0, { animate: true }); -}); -this._listen(c, "keydown", (e) => this._onA11yKey(e)); -}, -_a11yPointGroups() { -return (this.gpuTraces || []).filter((g) => -markOf(g.trace.kind).pointPick && g.tier !== "density" && g._cpu && -g._cpu.x && g._cpu.y && Math.min(g._cpu.x.length, g._cpu.y.length) > 0); -}, -_onA11yKey(e) { -const direction = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key]; -if (direction === undefined && e.key !== "Home" && e.key !== "End" && e.key !== "Escape") { -return; -} -if (e.key === "Escape") { -e.preventDefault(); -const hadHover = this._hoverId !== -1; -this.tooltip.style.display = "none"; -this._hoverId = -1; -this._hoverTarget = null; -this._lastHoverXY = null; -this._a11yKeyboardReadout = null; -this._pickSeq = (this._pickSeq || 0) + 1; -if (this.a11yLive) this.a11yLive.textContent = "Readout closed."; -if (hadHover && this._interactionFlag("hover")) { -this._dispatchChartEvent("leave", { view: this._eventView("leave") }); -} -if (hadHover) this._drawKeepPick(); -return; -} -e.preventDefault(); -if (this._transitionActive()) return; -const groups = this._a11yPointGroups(); -const total = groups.reduce((sum, g) => sum + Math.min(g._cpu.x.length, g._cpu.y.length), 0); -if (!total) return; -let flat = Number.isInteger(this._a11yPointIndex) ? this._a11yPointIndex : -1; -if (e.key === "Home") flat = 0; -else if (e.key === "End") flat = total - 1; -else if (flat < 0) flat = direction > 0 ? 0 : total - 1; -else flat = Math.max(0, Math.min(total - 1, flat + direction)); -this._a11yPointIndex = flat; -let offset = flat; -let g = groups[0]; -for (const candidate of groups) { -const n = Math.min(candidate._cpu.x.length, candidate._cpu.y.length); -if (offset < n) { g = candidate; break; } -offset -= n; -} -const hit = { trace: g.trace.id, index: offset, g }; -const xValue = this._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, offset); -const yValue = this._decodeValue(g._cpu.y, g._cpu.yMeta || g.yMeta, offset); -const x = this._dataPx(g.xAxis || "x", xValue) - this.plot.x; -const y = this._dataPx(g.yAxis || "y", yValue) - this.plot.y; -const rect = this.canvas.getBoundingClientRect(); -const clientX = rect.left + Math.max(0, Math.min(rect.width, x)); -const clientY = rect.top + Math.max(0, Math.min(rect.height, y)); -this._hoverId = hit.trace * 1e9 + hit.index; -this._hoverTarget = hit; -this._lastHoverXY = { clientX, clientY }; -this._a11yKeyboardReadout = { flat, total }; -this._showTooltip(hit, clientX, clientY); -this._drawKeepPick(); -}, -_updateCrosshair(e) { -if (!this.crosshairX || !this.crosshairY) return; -const rect = this.canvas.getBoundingClientRect(); -const rootRect = this.root.getBoundingClientRect(); -const x = e.clientX - rect.left; -const y = e.clientY - rect.top; -if (x < 0 || x > rect.width || y < 0 || y > rect.height) { -this._hideCrosshair(); -return; -} -const left = e.clientX - rootRect.left; -const top = e.clientY - rootRect.top; -this.crosshairX.style.display = "block"; -this.crosshairX.style.left = left + "px"; -this.crosshairX.style.top = this.plot.y + "px"; -this.crosshairX.style.height = this.plot.h + "px"; -this.crosshairY.style.display = "block"; -this.crosshairY.style.left = this.plot.x + "px"; -this.crosshairY.style.top = top + "px"; -this.crosshairY.style.width = this.plot.w + "px"; -}, -_hideCrosshair() { -if (this.crosshairX) this.crosshairX.style.display = "none"; -if (this.crosshairY) this.crosshairY.style.display = "none"; -}, -_click(e) { -if (this._ignoreNextClick) { -this._ignoreNextClick = false; -return; -} -if (!this._interactionFlag("click")) return; -const rect = this.canvas.getBoundingClientRect(); -const cssX = e.clientX - rect.left; -const cssY = e.clientY - rect.top; -const [x, y] = this._dataFromCanvas(cssX, cssY); -const hit = this._pickAt(cssX, cssY) || this._hoverAt(cssX, cssY); -const detail = { -x, -y, -view: this._eventView("click"), -row: hit && this._localRow ? this._localRow(hit) : null, -trace: hit ? hit.trace : null, -index: hit ? hit.index : null, -}; -this._dispatchChartEvent("click", detail); -if (hit && this.comm) { -const msg = { type: "click", trace: hit.trace, index: hit.index }; -const g = hit.g; -if (g && g.tier === "density" && g.drill && g.drill.seq !== undefined) { -msg.drill_seq = g.drill.seq; -} -this.comm.send(msg); -} -}, -_updateBand(band, e) { -const rect = this.canvas.getBoundingClientRect(); -const rootRect = this.root.getBoundingClientRect(); -if (band.mode === "select-lasso") { -const previous = band.points[band.points.length - 1]; -const cssX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); -const cssY = Math.max(0, Math.min(rect.height, e.clientY - rect.top)); -const clientX = rect.left + cssX; -const clientY = rect.top + cssY; -if (band.points.length < 2048 -&& Math.hypot(clientX - previous.x, clientY - previous.y) >= 3) { -band.points.push({ x: clientX, y: clientY, data: this._dataFromCanvas(cssX, cssY) }); -} -const points = band.points.map((point) => [ -Math.max(this.plot.x, Math.min(this.plot.x + this.plot.w, point.x - rootRect.left)), -Math.max(this.plot.y, Math.min(this.plot.y + this.plot.h, point.y - rootRect.top)), -]); -this.selLasso.style.display = "block"; -this.selLasso.style.inset = "0"; -this.selLasso.setAttribute("width", String(this.root.clientWidth)); -this.selLasso.setAttribute("height", String(this.root.clientHeight)); -this.selLassoPath.setAttribute( -"d", points.map((point, i) => `${i ? "L" : "M"}${point[0]} ${point[1]}`).join(" ") + " Z" -); -return; -} -const x = Math.min(band.sx, e.clientX) - rootRect.left; -const y = Math.min(band.sy, e.clientY) - rootRect.top; -const w = Math.abs(e.clientX - band.sx); -const h = Math.abs(e.clientY - band.sy); -const px = this.plot.x, py = this.plot.y; -const x2 = Math.min(x + w, px + this.plot.w), y2 = Math.min(y + h, py + this.plot.h); -let cx = Math.max(x, px), cy = Math.max(y, py); -let bx2 = x2, by2 = y2; -if (band.mode === "select-x") { cy = py; by2 = py + this.plot.h; } -if (band.mode === "select-y") { cx = px; bx2 = px + this.plot.w; } -this.selRect.dataset.fcBand = band.mode === "zoom" ? "zoom" : "select"; -this.selRect.style.display = "block"; -this.selRect.style.left = cx + "px"; -this.selRect.style.top = cy + "px"; -this.selRect.style.width = Math.max(0, bx2 - cx) + "px"; -this.selRect.style.height = Math.max(0, by2 - cy) + "px"; -void rect; -}, -_simplifyLassoPoints(points, tolerance = 6, maxPoints = 16) { -const source = points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y)); -if (source.length > 3) { -const first = source[0], last = source[source.length - 1]; -if (Math.hypot(first.x - last.x, first.y - last.y) <= tolerance) source.pop(); -} -if (source.length <= 3) return source.slice(); -const distanceToSegmentSq = (point, start, end) => { -const dx = end.x - start.x, dy = end.y - start.y; -if (dx === 0 && dy === 0) { -return (point.x - start.x) ** 2 + (point.y - start.y) ** 2; -} -const t = Math.max(0, Math.min(1, -((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy) -)); -const x = start.x + t * dx, y = start.y + t * dy; -return (point.x - x) ** 2 + (point.y - y) ** 2; -}; -const simplifyAt = (currentTolerance) => { -const keep = new Uint8Array(source.length); -keep[0] = 1; -keep[source.length - 1] = 1; -const stack = [[0, source.length - 1]]; -const toleranceSq = currentTolerance * currentTolerance; -while (stack.length) { -const [start, end] = stack.pop(); -let furthest = -1, furthestDistance = toleranceSq; -for (let i = start + 1; i < end; i++) { -const distance = distanceToSegmentSq(source[i], source[start], source[end]); -if (distance > furthestDistance) { -furthest = i; -furthestDistance = distance; -} -} -if (furthest >= 0) { -keep[furthest] = 1; -stack.push([start, furthest], [furthest, end]); -} -} -return source.filter((_point, index) => keep[index]); -}; -let simplified = simplifyAt(tolerance); -if (simplified.length < 3) { -simplified = [source[0], source[Math.floor(source.length / 2)], source[source.length - 1]]; -} -if (simplified.length > maxPoints) { -let low = tolerance; -let high = Math.max(tolerance, 1); -for (let i = 0; i < 16 && simplified.length > maxPoints; i++) { -low = high; -high *= 2; -simplified = simplifyAt(high); -} -for (let i = 0; i < 12; i++) { -const middle = (low + high) / 2; -const candidate = simplifyAt(middle); -if (candidate.length > maxPoints) low = middle; -else { -high = middle; -simplified = candidate; -} -} -if (simplified.length < 3) { -simplified = [source[0], source[Math.floor(source.length / 2)], source[source.length - 1]]; -} -} -return simplified; -}, -_clearLassoOverlay() { -this._lassoPolygon = null; -if (!this.selLasso) return; -this.selLasso.style.display = "none"; -this.selLassoPath?.removeAttribute("d"); -this.selLassoHandles?.replaceChildren(); -}, -_renderLassoSelection() { -const polygon = this._lassoPolygon; -if (!this.selLasso || !this.selLassoPath || !this.selLassoHandles -|| !Array.isArray(polygon) || polygon.length < 3) return; -const [x0, x1] = this._axisRange("x"); -const [y0, y1] = this._axisRange("y"); -const xAxis = this._axis("x"), yAxis = this._axis("y"); -const cx0 = this._axisCoord(xAxis, x0), cx1 = this._axisCoord(xAxis, x1); -const cy0 = this._axisCoord(yAxis, y0), cy1 = this._axisCoord(yAxis, y1); -if (![cx0, cx1, cy0, cy1].every(Number.isFinite) || cx0 === cx1 || cy0 === cy1) return; -const points = polygon.map((point) => { -const cx = this._axisCoord(xAxis, point[0]); -const cy = this._axisCoord(yAxis, point[1]); -const x = this.plot.x + ((cx - cx0) / (cx1 - cx0)) * this.plot.w; -const y = this.plot.y + ((cy1 - cy) / (cy1 - cy0)) * this.plot.h; -return [ -Math.max(this.plot.x, Math.min(this.plot.x + this.plot.w, x)), -Math.max(this.plot.y, Math.min(this.plot.y + this.plot.h, y)), -]; -}); -if (!points.flat().every(Number.isFinite)) return; -this.selLasso.style.display = "block"; -this.selLasso.style.inset = "0"; -this.selLasso.setAttribute("width", String(this.root.clientWidth)); -this.selLasso.setAttribute("height", String(this.root.clientHeight)); -this.selLassoPath.setAttribute( -"d", points.map((point, index) => `${index ? "L" : "M"}${point[0]} ${point[1]}`).join(" ") + " Z" -); -while (this.selLassoHandles.childElementCount < points.length) { -const handle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); -handle.dataset.fcSelectionLassoHandle = ""; -handle.setAttribute("r", "4"); -this.selLassoHandles.appendChild(handle); -} -while (this.selLassoHandles.childElementCount > points.length) { -this.selLassoHandles.lastElementChild.remove(); -} -[...this.selLassoHandles.children].forEach((handle, index) => { -handle.dataset.fcSelectionLassoHandle = String(index); -handle.setAttribute("cx", String(points[index][0])); -handle.setAttribute("cy", String(points[index][1])); -handle.setAttribute("aria-label", `Lasso point ${index + 1}`); -}); -}, -_sendSelect(d0, d1) { -this._clearLassoOverlay(); -const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); -const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); -const range = { x0, x1, y0, y1 }; -this._dispatchChartEvent("brush", { range, view: this._eventView("brush") }); -if (this.comm) { -this.comm.send({ type: "select", x0, x1, y0, y1 }); -} else { -this._selectLocal(x0, x1, y0, y1); -} -}, -_sendSelectPolygon(points) { -if (!Array.isArray(points) || points.length < 3) return; -const polygon = points.map((point) => [point[0], point[1]]); -if (!polygon.every((point) => point.every(Number.isFinite))) return; -this._lassoPolygon = polygon; -this._renderLassoSelection(); -this._dispatchChartEvent("brush", { -polygon, -view: this._eventView("brush"), -}); -if (this.comm) { -this.comm.send({ type: "select_polygon", points: polygon }); -} else { -this._selectLocalPolygon(polygon); -} -}, -_selectLocalPolygon(points) { -const xs = points.map((point) => point[0]); -const ys = points.map((point) => point[1]); -const minX = Math.min(...xs), maxX = Math.max(...xs); -const minY = Math.min(...ys), maxY = Math.max(...ys); -const inside = (x, y) => { -let hit = false; -for (let i = 0, j = points.length - 1; i < points.length; j = i++) { -const [xi, yi] = points[i], [xj, yj] = points[j]; -if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; -} -return hit; -}; -let total = 0; -for (const g of this.gpuTraces) { -if (!g._cpu || g.tier === "density") continue; -const cx = g._cpu.x, cy = g._cpu.y; -const xMeta = g._cpu.xMeta || g.xMeta; -const yMeta = g._cpu.yMeta || g.yMeta; -const ox = xMeta.offset, sx = xMeta.scale || 1; -const oy = yMeta.offset, sy = yMeta.scale || 1; -const mask = new Float32Array(g.n); -let count = 0; -for (let i = 0; i < g.n; i++) { -const x = cx[i] / sx + ox; -const y = cy[i] / sy + oy; -if (x >= minX && x <= maxX && y >= minY && y <= maxY && inside(x, y)) { -mask[i] = 1; -count++; -} -} -this._applySelMask(g, mask); -total += count; -} -this._selectionCount = total; -this.draw(); -this._dispatchChartEvent("select", { -total, -polygon: points, -view: this._eventView("select"), -}); -}, -_selectLocal(x0, x1, y0, y1) { -let total = 0; -for (const g of this.gpuTraces) { -if (!g._cpu || g.tier === "density") continue; -const cx = g._cpu.x, cy = g._cpu.y; -const xMeta = g._cpu.xMeta || g.xMeta; -const yMeta = g._cpu.yMeta || g.yMeta; -const ox = xMeta.offset, sx = xMeta.scale || 1; -const oy = yMeta.offset, sy = yMeta.scale || 1; -const mask = new Float32Array(g.n); -let cnt = 0; -for (let i = 0; i < g.n; i++) { -const dx = cx[i] / sx + ox, dy = cy[i] / sy + oy; -if (dx >= x0 && dx <= x1 && dy >= y0 && dy <= y1) { mask[i] = 1; cnt++; } -} -this._applySelMask(g, mask); -total += cnt; -} -this._selectionCount = total; -this.draw(); -this._dispatchChartEvent("select", { -total, -range: { x0, x1, y0, y1 }, -view: this._eventView("select"), -}); -}, -_applySelMask(g, maskF32) { -const gl = this.gl; -if (!g.selBuf) g.selBuf = gl.createBuffer(); -gl.bindBuffer(gl.ARRAY_BUFFER, g.selBuf); -gl.bufferData(gl.ARRAY_BUFFER, maskF32, gl.STATIC_DRAW); -g.selActive = true; -}, -_clearSelection() { -this._clearLassoOverlay(); -for (const g of this.gpuTraces) { -g.selActive = false; -if (g.drill) g.drill.selActive = false; -} -this._selectionCount = 0; -if (this._interactionFlag("select", true)) { -if (this.comm) this.comm.send({ type: "select_clear" }); -this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); -} -}, -_clampModebar(left, top) { -const bar = this._modebar; -if (!bar || !this.root) return; -const currentLeft = left ?? (Number.parseFloat(bar.style.left) || 0); -const currentTop = top ?? (Number.parseFloat(bar.style.top) || 0); -const maxLeft = Math.max(0, this.root.clientWidth - bar.offsetWidth); -const maxTop = Math.max(0, this.root.clientHeight - bar.offsetHeight); -bar.style.left = `${Math.max(0, Math.min(maxLeft, currentLeft))}px`; -bar.style.top = `${Math.max(0, Math.min(maxTop, currentTop))}px`; -}, -_buildModebar(root) { -if (this.spec.show_modebar === false) return; -const bar = document.createElement("div"); -bar.style.cssText = -`position:absolute;top:${this.plot.y + 4}px;left:${this.plot.x + 4}px;z-index:6;` + -"display:flex;opacity:0;pointer-events:none;transition:opacity .15s;"; -this._applySlot(bar, "modebar"); -bar.setAttribute("role", "toolbar"); -bar.setAttribute("aria-label", "Chart controls"); -this._modebar = bar; -this._modeBtns = {}; -this._modebarMoved = false; -let setZoomMenuOpen = () => {}; -let setSelectMenuOpen = () => {}; -let setExportMenuOpen = () => {}; -const setVisible = (visible) => { -const show = visible || this._modebarDragging || bar.contains(document.activeElement); -bar.style.opacity = show ? "1" : "0"; -bar.style.pointerEvents = show ? "auto" : "none"; -}; -this._listen(root, "pointerenter", () => setVisible(true)); -this._listen(root, "pointerleave", () => { -setVisible(false); -setZoomMenuOpen(false); -setSelectMenuOpen(false); -setExportMenuOpen(false); -}); -this._listen(bar, "focusin", () => setVisible(true)); -this._listen(bar, "focusout", (e) => { -if (!bar.contains(e.relatedTarget) && !root.matches(":hover")) setVisible(false); -}); -const grip = document.createElement("button"); -grip.type = "button"; -grip.title = "Click for toolbar options; drag to move"; -grip.setAttribute("aria-label", "Toolbar options"); -grip.setAttribute("aria-haspopup", "menu"); -grip.setAttribute("aria-expanded", "false"); -grip.dataset.fcModebarDragHandle = ""; -grip.dataset.fcModebarExport = ""; -grip.dataset.fcModebarExportTrigger = ""; -grip.innerHTML = this._icon("drag"); -grip.style.cssText = -"display:flex;align-items:center;justify-content:center;pointer-events:auto;touch-action:none;"; -this._applySlot(grip, "modebar_button"); -bar.appendChild(grip); -const DRAG_THRESHOLD_PX = 6; -let modebarDrag = null; -let suppressGripClickUntil = 0; -this._listen(grip, "pointerdown", (e) => { -if (e.pointerType === "mouse" && e.button !== 0) return; -e.stopPropagation(); -const barRect = bar.getBoundingClientRect(); -modebarDrag = { -pointerId: e.pointerId, -startX: e.clientX, -startY: e.clientY, -dx: e.clientX - barRect.left, -dy: e.clientY - barRect.top, -moved: false, -}; -try { grip.setPointerCapture(e.pointerId); } catch (_err) { } -setVisible(true); -}); -this._listen(grip, "pointermove", (e) => { -if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; -const distance = Math.hypot(e.clientX - modebarDrag.startX, e.clientY - modebarDrag.startY); -if (!modebarDrag.moved) { -if (distance < DRAG_THRESHOLD_PX) return; -modebarDrag.moved = true; -this._modebarDragging = true; -this._modebarMoved = true; -bar.style.transition = "none"; -setZoomMenuOpen(false); -setSelectMenuOpen(false); -setExportMenuOpen(false); -} -const rootRect = root.getBoundingClientRect(); -const left = e.clientX - rootRect.left - modebarDrag.dx; -const top = e.clientY - rootRect.top - modebarDrag.dy; -this._clampModebar(left, top); -}); -const endModebarDrag = (e) => { -if (!modebarDrag || e.pointerId !== modebarDrag.pointerId) return; -const moved = modebarDrag.moved; -const cancelled = e.type === "pointercancel"; -modebarDrag = null; -this._modebarDragging = false; -bar.style.transition = "opacity .15s"; -setVisible(root.matches(":hover")); -if (moved || cancelled) { -suppressGripClickUntil = performance.now() + 100; -} -}; -this._listen(grip, "pointerup", endModebarDrag); -this._listen(grip, "pointercancel", endModebarDrag); -this._listen(grip, "click", (e) => { -e.stopPropagation(); -if (performance.now() <= suppressGripClickUntil) { -suppressGripClickUntil = 0; -return; -} -setExportMenuOpen(!this._exportMenuOpen); -}); -const mk = (name, title, onClick, toggles) => { -const b = document.createElement("button"); -b.type = "button"; -b.title = title; -b.setAttribute("aria-label", title); -if (toggles) b.setAttribute("aria-pressed", "false"); -b.innerHTML = this._icon(name); -b.style.cssText = -"display:flex;align-items:center;justify-content:center;pointer-events:auto;"; -this._applySlot(b, "modebar_button"); -this._listen(b, "pointerdown", (e) => e.stopPropagation()); -this._listen(b, "click", (e) => { e.stopPropagation(); onClick(); }); -bar.appendChild(b); -if (toggles) this._modeBtns[toggles] = b; -return b; -}; -const zoomTrigger = mk("zoommenu", "Zoom controls", () => { -setZoomMenuOpen(!this._zoomMenuOpen); -}); -this._zoomMenuButton = zoomTrigger; -zoomTrigger.dataset.fcModebarMenuTrigger = ""; -zoomTrigger.replaceChildren(); -const zoomPercent = document.createElement("span"); -zoomPercent.dataset.fcModebarZoomPercent = ""; -zoomPercent.textContent = "100%"; -zoomTrigger.appendChild(zoomPercent); -const zoomIndicator = document.createElement("span"); -zoomIndicator.dataset.fcModebarMenuIndicator = ""; -zoomIndicator.innerHTML = this._icon("chevrondown"); -zoomTrigger.appendChild(zoomIndicator); -this._zoomMenuLabel = zoomPercent; -zoomTrigger.setAttribute("aria-haspopup", "menu"); -zoomTrigger.setAttribute("aria-expanded", "false"); -const canSelect = this._pickable -&& this._interactionFlag("brush", true) -&& this._interactionFlag("select", true); -let selectTrigger = null; -let selectIndicator = null; -if (canSelect) { -selectTrigger = mk("select", "Selection controls", () => { -setSelectMenuOpen(!this._selectMenuOpen); -}); -selectTrigger.dataset.fcModebarSelect = ""; -selectTrigger.dataset.fcModebarSelectTrigger = ""; -selectTrigger.setAttribute("aria-haspopup", "menu"); -selectTrigger.setAttribute("aria-expanded", "false"); -selectIndicator = document.createElement("span"); -selectIndicator.dataset.fcModebarMenuIndicator = ""; -selectIndicator.innerHTML = this._icon("chevrondown"); -selectTrigger.appendChild(selectIndicator); -this._selectMenuButton = selectTrigger; -} -mk("pan", "Pan", () => this._setDragMode("pan"), "pan"); -const zoomMenu = document.createElement("div"); -zoomMenu.dataset.fcModebarMenu = ""; -zoomMenu.setAttribute("role", "menu"); -zoomMenu.setAttribute("aria-label", "Zoom controls"); -zoomMenu.style.cssText = -"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; -bar.appendChild(zoomMenu); -const zoomMenuItems = []; -const mkZoomItem = (name, label, onClick, toggles, separator = false) => { -const button = document.createElement("button"); -button.type = "button"; -button.tabIndex = -1; -button.dataset.fcModebarMenuItem = name; -if (separator) button.dataset.fcSeparator = ""; -button.setAttribute("role", "menuitem"); -button.style.cssText = -"display:flex;align-items:center;pointer-events:auto;"; -this._applySlot(button, "modebar_button"); -const icon = document.createElement("span"); -icon.dataset.fcModebarMenuIcon = ""; -icon.innerHTML = this._icon(name); -button.appendChild(icon); -const text = document.createElement("span"); -text.textContent = label; -button.appendChild(text); -this._listen(button, "pointerdown", (e) => e.stopPropagation()); -this._listen(button, "click", (e) => { -e.stopPropagation(); -setZoomMenuOpen(false, true); -onClick(); -}); -zoomMenu.appendChild(button); -zoomMenuItems.push(button); -if (toggles) this._modeBtns[toggles] = button; -return button; -}; -const resetView = () => { -this._clearSelection(); -this._setView(this.view0, { animate: true }); -}; -mkZoomItem("zoomin", "Zoom In", () => this._zoomBy(0.5, true)); -mkZoomItem("zoomout", "Zoom Out", () => this._zoomBy(2, true)); -mkZoomItem("zoom", "Box Zoom", () => this._setDragMode("zoom"), "zoom"); -mkZoomItem("reset", "Reset View", resetView, null, true); -const selectMenu = document.createElement("div"); -selectMenu.dataset.fcModebarMenu = ""; -selectMenu.dataset.fcModebarSelectMenu = ""; -selectMenu.setAttribute("role", "menu"); -selectMenu.setAttribute("aria-label", "Selection controls"); -selectMenu.style.cssText = -"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; -bar.appendChild(selectMenu); -const selectMenuItems = []; -const mkSelectItem = (name, label, mode) => { -const button = document.createElement("button"); -button.type = "button"; -button.tabIndex = -1; -button.dataset.fcModebarMenuItem = name; -button.dataset.fcModebarSelectItem = mode; -button.setAttribute("role", "menuitem"); -button.style.cssText = "display:flex;align-items:center;pointer-events:auto;"; -this._applySlot(button, "modebar_button"); -const icon = document.createElement("span"); -icon.dataset.fcModebarMenuIcon = ""; -icon.innerHTML = this._icon(name); -button.appendChild(icon); -const text = document.createElement("span"); -text.textContent = label; -button.appendChild(text); -this._listen(button, "pointerdown", (e) => e.stopPropagation()); -this._listen(button, "click", (e) => { -e.stopPropagation(); -setSelectMenuOpen(false, true); -this._setDragMode(mode); -}); -selectMenu.appendChild(button); -selectMenuItems.push(button); -this._modeBtns[mode] = button; -}; -if (canSelect) { -mkSelectItem("select", "Box Select", "select"); -mkSelectItem("lasso", "Lasso Select", "select-lasso"); -mkSelectItem("selectx", "X Range", "select-x"); -mkSelectItem("selecty", "Y Range", "select-y"); -} -const exportMenu = document.createElement("div"); -exportMenu.dataset.fcModebarMenu = ""; -exportMenu.dataset.fcModebarExportMenu = ""; -exportMenu.setAttribute("role", "menu"); -exportMenu.setAttribute("aria-label", "Toolbar options"); -exportMenu.style.cssText = -"position:absolute;display:none;flex-direction:column;z-index:7;pointer-events:auto;"; -bar.appendChild(exportMenu); -const exportMenuItems = []; -const mkExportItem = (name, label, onClick, separator = false) => { -const button = document.createElement("button"); -button.type = "button"; -button.tabIndex = -1; -button.dataset.fcModebarMenuItem = name; -button.dataset.fcModebarExportItem = name; -if (separator) button.dataset.fcSeparator = ""; -button.setAttribute("role", "menuitem"); -button.style.cssText = "display:flex;align-items:center;pointer-events:auto;"; -this._applySlot(button, "modebar_button"); -const icon = document.createElement("span"); -icon.dataset.fcModebarMenuIcon = ""; -icon.innerHTML = this._icon(name); -button.appendChild(icon); -const text = document.createElement("span"); -text.textContent = label; -button.appendChild(text); -this._listen(button, "pointerdown", (e) => e.stopPropagation()); -this._listen(button, "click", (e) => { -e.stopPropagation(); -setExportMenuOpen(false, true); -Promise.resolve(onClick()).catch((error) => console.error(`xy: ${label} failed`, error)); -}); -exportMenu.appendChild(button); -exportMenuItems.push(button); -return button; -}; -mkExportItem("png", "Export PNG", () => this._exportPng()); -mkExportItem("svg", "Export SVG", () => this._exportSvg()); -mkExportItem("csv", "Export CSV", () => this._exportCsv()); -setZoomMenuOpen = (open, restoreFocus = false) => { -const show = Boolean(open); -if (show) { -setSelectMenuOpen(false); -setExportMenuOpen(false); -} -this._zoomMenuOpen = show; -zoomTrigger.setAttribute("aria-expanded", String(show)); -if (!show) { -zoomMenu.style.display = "none"; -zoomIndicator.style.transform = "none"; -if (restoreFocus) zoomTrigger.focus(); -return; -} -zoomMenu.style.display = "flex"; -zoomMenu.style.visibility = "hidden"; -const rootRect = root.getBoundingClientRect(); -const barRect = bar.getBoundingClientRect(); -const rootLeft = barRect.left - rootRect.left; -const rootTop = barRect.top - rootRect.top; -const below = bar.offsetHeight + 6; -const above = -zoomMenu.offsetHeight - 6; -const preferredTop = barRect.bottom + 6 + zoomMenu.offsetHeight <= rootRect.bottom -? below -: above; -zoomIndicator.style.transform = preferredTop === above ? "rotate(180deg)" : "none"; -const maxLeft = root.clientWidth - rootLeft - zoomMenu.offsetWidth; -const maxTop = root.clientHeight - rootTop - zoomMenu.offsetHeight; -zoomMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, zoomTrigger.offsetLeft))}px`; -zoomMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; -zoomMenu.style.visibility = "visible"; -}; -setSelectMenuOpen = (open, restoreFocus = false) => { -if (!selectTrigger) return; -const show = Boolean(open); -if (show) { -setZoomMenuOpen(false); -setExportMenuOpen(false); -} -this._selectMenuOpen = show; -selectTrigger.setAttribute("aria-expanded", String(show)); -if (!show) { -selectMenu.style.display = "none"; -selectIndicator.style.transform = "none"; -if (restoreFocus) selectTrigger.focus(); -return; -} -selectMenu.style.display = "flex"; -selectMenu.style.visibility = "hidden"; -const rootRect = root.getBoundingClientRect(); -const barRect = bar.getBoundingClientRect(); -const rootLeft = barRect.left - rootRect.left; -const rootTop = barRect.top - rootRect.top; -const below = bar.offsetHeight + 6; -const above = -selectMenu.offsetHeight - 6; -const preferredTop = barRect.bottom + 6 + selectMenu.offsetHeight <= rootRect.bottom -? below -: above; -selectIndicator.style.transform = preferredTop === above ? "rotate(180deg)" : "none"; -const maxLeft = root.clientWidth - rootLeft - selectMenu.offsetWidth; -const maxTop = root.clientHeight - rootTop - selectMenu.offsetHeight; -selectMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, selectTrigger.offsetLeft))}px`; -selectMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; -selectMenu.style.visibility = "visible"; -}; -setExportMenuOpen = (open, restoreFocus = false) => { -const show = Boolean(open); -if (show) { -setZoomMenuOpen(false); -setSelectMenuOpen(false); -} -this._exportMenuOpen = show; -grip.setAttribute("aria-expanded", String(show)); -if (!show) { -exportMenu.style.display = "none"; -if (restoreFocus) grip.focus(); -return; -} -exportMenu.style.display = "flex"; -exportMenu.style.visibility = "hidden"; -const rootRect = root.getBoundingClientRect(); -const barRect = bar.getBoundingClientRect(); -const rootLeft = barRect.left - rootRect.left; -const rootTop = barRect.top - rootRect.top; -const below = bar.offsetHeight + 6; -const above = -exportMenu.offsetHeight - 6; -const preferredTop = barRect.bottom + 6 + exportMenu.offsetHeight <= rootRect.bottom -? below -: above; -const maxLeft = root.clientWidth - rootLeft - exportMenu.offsetWidth; -const maxTop = root.clientHeight - rootTop - exportMenu.offsetHeight; -exportMenu.style.left = `${Math.max(-rootLeft, Math.min(maxLeft, grip.offsetLeft))}px`; -exportMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px`; -exportMenu.style.visibility = "visible"; -}; -this._closeModebarMenu = () => { -setZoomMenuOpen(false); -setSelectMenuOpen(false); -setExportMenuOpen(false); -}; -this._listen(document, "pointerdown", (e) => { -if (this._zoomMenuOpen && !bar.contains(e.target)) setZoomMenuOpen(false); -if (this._selectMenuOpen && !bar.contains(e.target)) setSelectMenuOpen(false); -if (this._exportMenuOpen && !bar.contains(e.target)) setExportMenuOpen(false); -}); -this._listen(zoomTrigger, "keydown", (e) => { -if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; -e.preventDefault(); -e.stopPropagation(); -setZoomMenuOpen(true); -const index = e.key === "ArrowDown" ? 0 : zoomMenuItems.length - 1; -zoomMenuItems[index].focus(); -}); -this._listen(zoomMenu, "keydown", (e) => { -if (e.key === "Escape") { -e.preventDefault(); -e.stopPropagation(); -setZoomMenuOpen(false, true); -return; -} -if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; -e.preventDefault(); -const current = zoomMenuItems.indexOf(document.activeElement); -let next = e.key === "Home" ? 0 : e.key === "End" ? zoomMenuItems.length - 1 : current; -if (e.key === "ArrowDown") next = (current + 1) % zoomMenuItems.length; -if (e.key === "ArrowUp") next = (current - 1 + zoomMenuItems.length) % zoomMenuItems.length; -zoomMenuItems[next].focus(); -}); -if (selectTrigger) { -this._listen(selectTrigger, "keydown", (e) => { -if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; -e.preventDefault(); -e.stopPropagation(); -setSelectMenuOpen(true); -const index = e.key === "ArrowDown" ? 0 : selectMenuItems.length - 1; -selectMenuItems[index].focus(); -}); -this._listen(selectMenu, "keydown", (e) => { -if (e.key === "Escape") { -e.preventDefault(); -e.stopPropagation(); -setSelectMenuOpen(false, true); -return; -} -if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; -e.preventDefault(); -const current = selectMenuItems.indexOf(document.activeElement); -let next = e.key === "Home" ? 0 : e.key === "End" ? selectMenuItems.length - 1 : current; -if (e.key === "ArrowDown") next = (current + 1) % selectMenuItems.length; -if (e.key === "ArrowUp") { -next = (current - 1 + selectMenuItems.length) % selectMenuItems.length; -} -selectMenuItems[next].focus(); -}); -} -this._listen(grip, "keydown", (e) => { -if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; -e.preventDefault(); -e.stopPropagation(); -setExportMenuOpen(true); -const index = e.key === "ArrowDown" ? 0 : exportMenuItems.length - 1; -exportMenuItems[index].focus(); -}); -this._listen(exportMenu, "keydown", (e) => { -if (e.key === "Escape") { -e.preventDefault(); -e.stopPropagation(); -setExportMenuOpen(false, true); -return; -} -if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; -e.preventDefault(); -const current = exportMenuItems.indexOf(document.activeElement); -let next = e.key === "Home" ? 0 : e.key === "End" ? exportMenuItems.length - 1 : current; -if (e.key === "ArrowDown") next = (current + 1) % exportMenuItems.length; -if (e.key === "ArrowUp") { -next = (current - 1 + exportMenuItems.length) % exportMenuItems.length; -} -exportMenuItems[next].focus(); -}); -root.appendChild(bar); -this._fitModebar(); -setVisible(root.matches(":hover")); -this._setDragMode(this.dragMode); -}, -_fitModebar() { -const bar = this._modebar; -if (!bar) return; -this._closeModebarMenu?.(); -if (!this._modebarMoved) { -bar.style.top = `${this.plot.y + 4}px`; -bar.style.left = `${this.plot.x + 4}px`; -} -bar.style.display = "flex"; -const fits = -bar.offsetWidth + 8 <= this.plot.w && bar.offsetHeight + 8 <= this.plot.h; -if (!fits) { -bar.style.display = "none"; -return; -} -this._clampModebar(); -}, -_setDragMode(mode) { -this.dragMode = mode; -if (this.canvas) this.canvas.dataset.fcDragmode = mode; -for (const [name, btn] of Object.entries(this._modeBtns || {})) { -btn.classList.toggle("fc-active", name === mode); -btn.setAttribute("aria-pressed", String(name === mode)); -} -this._zoomMenuButton?.classList.toggle("fc-active", mode === "zoom"); -this._selectMenuButton?.classList.toggle("fc-active", mode.startsWith("select")); -}, -_updateZoomMenuLabel() { -if (!this._zoomMenuLabel || !this.view || !this.view0) return; -const axisPercent = (axisId, lo, hi, homeLo, homeHi) => { -const axis = this._axis(axisId); -const span = Math.abs(this._axisCoord(axis, hi) - this._axisCoord(axis, lo)); -const homeSpan = Math.abs( -this._axisCoord(axis, homeHi) - this._axisCoord(axis, homeLo) -); -return Number.isFinite(span) && span > 0 && Number.isFinite(homeSpan) && homeSpan > 0 -? (homeSpan / span) * 100 -: null; -}; -const percent = axisPercent("x", this.view.x0, this.view.x1, this.view0.x0, this.view0.x1) -?? axisPercent("y", this.view.y0, this.view.y1, this.view0.y0, this.view0.y1) -?? 100; -const rounded = Math.round(percent); -const exactText = percent < 1 ? "<1%" : `${rounded}%`; -const displayText = rounded > 999 ? `${String(rounded).slice(0, 3)}…%` : exactText; -if (this._zoomMenuLabel.dataset.fcZoomExact === exactText -&& this._zoomMenuLabel.textContent === displayText) return; -this._zoomMenuLabel.textContent = displayText; -this._zoomMenuLabel.dataset.fcZoomExact = exactText; -this._zoomMenuButton.title = `Zoom controls (${exactText})`; -this._zoomMenuButton.setAttribute("aria-label", `Zoom controls, ${exactText}`); -}, -_prefersReducedMotion() { -return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; -}, -_cancelViewAnimation() { -if (this._animRaf) cancelAnimationFrame(this._animRaf); -this._animRaf = null; -this._viewAnim = null; -}, -_setView(next, opts = {}) { -if (this._destroyed) return; -const target = { x0: next.x0, x1: next.x1, y0: next.y0, y1: next.y1 }; -const animate = opts.animate === true && !this._prefersReducedMotion(); -const duration = opts.duration || 180; -if (!animate || duration <= 0) { -this._cancelViewAnimation(); -this.view = target; -this.draw(); -if (opts.request !== false) this._scheduleViewRequest(); -this._emitViewChange(opts.source || "view", { broadcast: opts.broadcast }); -return; -} -clearTimeout(this._viewTimer); -this.seq += 1; -const request = opts.request !== false; -const requestDelay = opts.requestDelay ?? Math.min(55, Math.max(24, duration * 0.35)); -const requestMaxWait = opts.requestMaxWait ?? 130; -if (request) { -this._scheduleViewRequest(target, { seq: this.seq, delay: requestDelay, maxWait: requestMaxWait }); -} -const now = this._now(); -const tau = Math.max(18, duration / 5); -if (this._viewAnim) { -this._viewAnim.target = target; -this._viewAnim.tau = tau; -return; -} -this._viewAnim = { -target, -last: now, -tau, -}; -const lerp = (a, b, t) => a + (b - a) * t; -const span = (v) => Math.max(Math.abs(v.x1 - v.x0), Math.abs(v.y1 - v.y0), 1e-12); -const closeEnough = (a, b) => { -const tol = span(b) * 1e-4; -return Math.max( -Math.abs(a.x0 - b.x0), Math.abs(a.x1 - b.x1), -Math.abs(a.y0 - b.y0), Math.abs(a.y1 - b.y1)) <= tol; -}; -const step = (nowFrame) => { -if (this._destroyed) { this._animRaf = null; return; } -const anim = this._viewAnim; -if (!anim) { this._animRaf = null; return; } -const dt = Math.max(0, Math.min(64, nowFrame - anim.last)); -anim.last = nowFrame; -const k = 1 - Math.exp(-dt / anim.tau); -const t = closeEnough(this.view, anim.target) ? 1 : k; -this.view = { -x0: lerp(this.view.x0, anim.target.x0, t), -x1: lerp(this.view.x1, anim.target.x1, t), -y0: lerp(this.view.y0, anim.target.y0, t), -y1: lerp(this.view.y1, anim.target.y1, t), -}; -if (t < 1) { -this.draw(); -this._animRaf = requestAnimationFrame(step); -} else { -this._animRaf = null; -this._viewAnim = null; -this.view = anim.target; -this._lastLabelDraw = null; -this.draw(); -this._emitViewChange(opts.source || "view", { broadcast: opts.broadcast }); -} -}; -this._animRaf = requestAnimationFrame(step); -}, -_zoomBy(f, animate = false) { -const base = this._viewAnim ? this._viewAnim.target : this.view; -const { x0, x1, y0, y1 } = base; -const xr = this._zoomAxisRange("x", x0, x1, f, 0.5); -const yr = this._zoomAxisRange("y", y0, y1, f, 0.5); -if (!xr || !yr) return; -this._setView({ x0: xr[0], x1: xr[1], y0: yr[0], y1: yr[1] }, { animate }); -}, -_zoomAxisRange(axisId, lo, hi, f, anchorFrac) { -const axis = this._axis(axisId); -const c0 = this._axisCoord(axis, lo); -const c1 = this._axisCoord(axis, hi); -if (![c0, c1].every(Number.isFinite) || c0 === c1) return null; -const ca = c0 + anchorFrac * (c1 - c0); -if (f < 1) { -const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12; -if (Math.abs((c1 - c0) * f) < minSpan) return null; -} -return [ -this._axisValue(axis, ca - (ca - c0) * f), -this._axisValue(axis, ca + (c1 - ca) * f), -]; -}, -_zoomAt(f, fx, fy, animate = false, duration = 120) { -const base = this._viewAnim ? this._viewAnim.target : this.view; -const { x0, x1, y0, y1 } = base; -const xr = this._zoomAxisRange("x", x0, x1, f, fx); -const yr = this._zoomAxisRange("y", y0, y1, f, fy); -if (!xr || !yr) return; -this._setView({ x0: xr[0], x1: xr[1], y0: yr[0], y1: yr[1] }, { animate, duration }); -}, -_queueWheelZoom(factor, fx, fy) { -if (!Number.isFinite(factor) || factor <= 0) return; -if (!this._pendingWheelZoom) { -this._pendingWheelZoom = { factor: 1, fx, fy }; -} -this._pendingWheelZoom.factor *= factor; -this._pendingWheelZoom.fx = fx; -this._pendingWheelZoom.fy = fy; -if (this._wheelZoomRaf) return; -this._wheelZoomRaf = requestAnimationFrame(() => { -this._wheelZoomRaf = null; -const pending = this._pendingWheelZoom; -this._pendingWheelZoom = null; -if (!pending || this._destroyed) return; -this._zoomAt(pending.factor, pending.fx, pending.fy, false); -}); -}, -_zoomToBox(d0, d1, animate = false) { -const xa = this._axis("x"); -const ya = this._axis("y"); -const xlo = Math.min(d0[0], d1[0]), xhi = Math.max(d0[0], d1[0]); -const ylo = Math.min(d0[1], d1[1]), yhi = Math.max(d0[1], d1[1]); -const cx0 = this._axisCoord(xa, xlo), cx1 = this._axisCoord(xa, xhi); -const cy0 = this._axisCoord(ya, ylo), cy1 = this._axisCoord(ya, yhi); -if (![cx0, cx1, cy0, cy1].every(Number.isFinite)) return; -const minSpanX = Math.max(Math.abs(cx0), Math.abs(cx1), 1e-30) * 1e-12; -const minSpanY = Math.max(Math.abs(cy0), Math.abs(cy1), 1e-30) * 1e-12; -if (Math.abs(cx1 - cx0) < minSpanX || Math.abs(cy1 - cy0) < minSpanY) return; -const xReversed = this.view.x1 < this.view.x0; -const yReversed = this.view.y1 < this.view.y0; -const x0 = xReversed ? xhi : xlo; -const x1 = xReversed ? xlo : xhi; -const y0 = yReversed ? yhi : ylo; -const y1 = yReversed ? ylo : yhi; -this._setView({ x0, x1, y0, y1 }, { animate }); -}, -_exportFilename(extension) { -const title = String(this.spec.title || "xy-chart") -.trim() -.toLowerCase() -.replace(/[^a-z0-9]+/g, "-") -.replace(/^-+|-+$/g, "") || "xy-chart"; -return `${title}.${extension}`; -}, -_downloadExport(blob, filename) { -const url = URL.createObjectURL(blob); -const link = document.createElement("a"); -link.href = url; -link.download = filename; -link.style.display = "none"; -document.body.appendChild(link); -link.click(); -link.remove(); -setTimeout(() => URL.revokeObjectURL(url), 0); -}, -_exportSvgMarkup() { -this._drawNow?.(); -this.gl?.finish?.(); -const width = this.size.w; -const height = this.size.h; -const clone = this.root.cloneNode(true); -clone.style.width = `${width}px`; -clone.style.height = `${height}px`; -clone.style.margin = "0"; -clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); -const computed = getComputedStyle(this.root); -const inheritedProperties = [ -"color", "font-family", "font-size", "font-style", "font-weight", -"letter-spacing", "line-height", -]; -const chartTokens = [ -"--chart-bg", "--chart-text", "--chart-grid", "--chart-axis", -"--chart-tooltip-bg", "--chart-tooltip-text", "--chart-legend-bg", -"--chart-badge-bg", "--chart-badge-text", "--chart-modebar-bg", -"--chart-modebar-active", "--chart-selection", "--chart-selection-fill", -"--chart-zoom-selection", "--chart-zoom-selection-fill", "--chart-crosshair", -"--chart-annotation-text", "--chart-cursor", "--chart-cursor-pan", -]; -for (let i = 0; i < computed.length; i++) { -const property = computed.item(i); -if (!property.startsWith("--")) continue; -const value = computed.getPropertyValue(property).trim(); -if (value) clone.style.setProperty(property, value); -} -for (const property of [...inheritedProperties, ...chartTokens]) { -const value = computed.getPropertyValue(property).trim(); -if (value) clone.style.setProperty(property, value); -} -const sourceCanvases = [...this.root.querySelectorAll("canvas")]; -const clonedCanvases = [...clone.querySelectorAll("canvas")]; -for (let i = 0; i < clonedCanvases.length; i++) { -const source = sourceCanvases[i]; -const target = clonedCanvases[i]; -if (!source || !target) continue; -const image = document.createElement("img"); -image.setAttribute("src", source.toDataURL("image/png")); -image.setAttribute("alt", ""); -image.setAttribute("style", target.getAttribute("style") || ""); -image.setAttribute("width", String(source.clientWidth || source.width)); -image.setAttribute("height", String(source.clientHeight || source.height)); -for (const attr of target.attributes) { -if (attr.name.startsWith("data-")) image.setAttribute(attr.name, attr.value); -} -target.replaceWith(image); -} -clone.querySelectorAll( -'[data-fc-slot="modebar"],[data-fc-slot="tooltip"],' + -'[data-fc-slot="selection"],[data-fc-selection-lasso-overlay],' + -'[data-fc-slot="crosshair_x"],[data-fc-slot="crosshair_y"]' -).forEach((node) => node.remove()); -const stylesheet = document.createElement("style"); -stylesheet.textContent = FC_CHROME_CSS; -clone.prepend(stylesheet); -const content = new XMLSerializer().serializeToString(clone); -return `` + -`${content}`; -}, -_exportSvg() { -const svg = this._exportSvgMarkup(); -this._downloadExport( -new Blob([svg], { type: "image/svg+xml;charset=utf-8" }), -this._exportFilename("svg") -); -}, -_exportPng() { -const svg = this._exportSvgMarkup(); -const sourceUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; -const image = new Image(); -return new Promise((resolve, reject) => { -image.onload = () => { -const scale = Math.max(1, window.devicePixelRatio || 1); -const canvas = document.createElement("canvas"); -canvas.width = Math.round(this.size.w * scale); -canvas.height = Math.round(this.size.h * scale); -const ctx = canvas.getContext("2d"); -ctx.scale(scale, scale); -ctx.drawImage(image, 0, 0, this.size.w, this.size.h); -canvas.toBlob((blob) => { -if (!blob) { -reject(new Error("PNG encoding returned no data")); -return; -} -this._downloadExport(blob, this._exportFilename("png")); -resolve(); -}, "image/png"); -}; -image.onerror = () => { -reject(new Error("chart SVG could not be rasterized")); -}; -image.src = sourceUrl; -}); -}, -_exportCsvText() { -const columns = ["trace", "name", "kind", "index", "x", "y", "x0", "x1", "y0", "y1", "value"]; -const rows = [columns]; -const clean = (value) => Number.isFinite(value) ? value : ""; -for (const g of this.gpuTraces || []) { -const trace = g.trace || {}; -const prefix = [trace.id ?? "", trace.name ?? "", trace.kind ?? ""]; -if (g._cpuRect) { -const r = g._cpuRect; -const n = Math.min(r.x0.length, r.x1.length, r.y0.length, r.y1.length); -for (let i = 0; i < n; i++) { -rows.push([...prefix, i, "", "", -clean(this._decodeValue(r.x0, r.x0Meta, i)), -clean(this._decodeValue(r.x1, r.x1Meta, i)), -clean(this._decodeValue(r.y0, r.y0Meta, i)), -clean(this._decodeValue(r.y1, r.y1Meta, i)), ""]); -} -continue; -} -if (g.heatmap && g._cpuHeatmap) { -const h = g.heatmap; -for (let i = 0; i < g._cpuHeatmap.grid.length; i++) { -const row = Math.floor(i / h.w); -const col = i % h.w; -const x = h.xRange[0] + (col + 0.5) * ((h.xRange[1] - h.xRange[0]) / h.w); -const y = h.yRange[0] + (row + 0.5) * ((h.yRange[1] - h.yRange[0]) / h.h); -const value = this._denormalizeUnit(g._cpuHeatmap.grid[i], trace.color?.domain); -rows.push([...prefix, i, clean(x), clean(y), "", "", "", "", clean(value)]); -} -continue; -} -const cpu = g._cpu; -if (!cpu?.x || !cpu?.y) continue; -const n = Math.min(cpu.x.length, cpu.y.length, g.n || Infinity); -for (let i = 0; i < n; i++) { -rows.push([...prefix, i, -clean(this._decodeValue(cpu.x, cpu.xMeta || g.xMeta, i)), -clean(this._decodeValue(cpu.y, cpu.yMeta || g.yMeta, i)), -"", "", "", "", ""]); -} -} -const quote = (value) => { -const text = String(value ?? ""); -const escaped = text.split('"').join('""'); -return text.includes(",") || text.includes('"') || text.includes("\r") || text.includes("\n") -? `"${escaped}"` -: text; -}; -return rows.map((row) => row.map(quote).join(",")).join("\r\n") + "\r\n"; -}, -_exportCsv() { -this._downloadExport( -new Blob([this._exportCsvText()], { type: "text/csv;charset=utf-8" }), -this._exportFilename("csv") -); -}, -_icon(name) { -const svg = (body) => -`${body}`; -switch (name) { -case "zoomin": -return svg('' + -''); -case "zoomout": -return svg('' + -''); -case "pan": -return svg('' + -'' + -''); -case "zoom": -return svg(''); -case "select": -return svg('' + -''); -case "lasso": -return svg('' + -''); -case "selectx": -return svg('' + -''); -case "selecty": -return svg('' + -''); -case "chevrondown": -return svg(''); -case "collapse": -return svg(''); -case "expand": -return svg(''); -case "png": -return svg('' + -''); -case "svg": -return svg('' + -''); -case "csv": -return svg('' + -''); -case "reset": -return svg(''); -case "drag": -return svg('' + -'' + -'' + -'' + -'' + -''); -default: -return svg(""); -} -}, -}); -Object.assign(ChartView.prototype, { -_scheduleViewRequest(viewOverride = this.view, opts = {}) { -if (this._destroyed || this._glLost) return; -if (!this.comm) { -this._scheduleSampleRebin(viewOverride, opts); -return; -} -const needsDecimated = this.spec.traces.some((t) => t.tier === "decimated"); -const needsDensity = this.gpuTraces.some((g) => g.tier === "density"); -if (!needsDecimated && !needsDensity) return; -const seq = opts.seq ?? ++this.seq; -const view = { ...viewOverride }; -const plotW = Math.round(this.plot.w); -const plotH = Math.round(this.plot.h); -if (needsDensity) { -const now = this._now(); -for (const g of this.gpuTraces) { -if (g.tier !== "density") continue; -g._lodPendingView = view; -g._lodPendingSeq = seq; -g._lodPendingAt = now; -} -} -let delay = opts.delay ?? 120; -if (opts.maxWait !== undefined && opts.maxWait !== null) { -const now = this._now(); -if (this._viewRequestBurstStart === undefined || this._viewRequestBurstStart === null) { -this._viewRequestBurstStart = now; -} -const remaining = opts.maxWait - (now - this._viewRequestBurstStart); -delay = remaining <= 0 ? 0 : Math.min(delay, remaining); -} else { -this._viewRequestBurstStart = null; -} -clearTimeout(this._viewTimer); -const send = () => { -if (this._destroyed) return; -this._viewRequestBurstStart = null; -if (seq !== this.seq) return; -if (needsDecimated) { -this.comm.send({ -type: "view", seq, -x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), px: plotW, -}); -} -if (needsDensity) { -for (const g of this.gpuTraces) { -if (g.tier !== "density") continue; -this.comm.send({ -type: "density_view", seq, trace: g.trace.id, -x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), -y0: Math.min(view.y0, view.y1), y1: Math.max(view.y0, view.y1), -w: plotW, h: plotH, -}); -} -} -}; -if (delay <= 0) { -send(); -} else { -this._viewTimer = setTimeout(send, delay); -} -return seq; -}, -_scheduleSampleRebin(viewOverride = this.view, opts = {}) { -if (this._destroyed || this._glLost || this._sampleRebinDisabled) return; -const targets = (this.gpuTraces || []).filter( -(g) => g.tier === "density" && g.sampleOverlay && g.sampleOverlay._cpu -); -if (!targets.length) return; -const seq = opts.seq ?? ++this.seq; -const view = { ...viewOverride }; -clearTimeout(this._rebinTimer); -this._rebinTimer = setTimeout(() => { -if (this._destroyed || seq !== this.seq) return; -for (const g of targets) this._requestSampleRebin(g, view, seq); -}, opts.delay ?? 120); -}, -_requestSampleRebin(g, view, seq) { -if (!g._homeDensity) g._homeDensity = g.density; -const v0 = this.view0; -const ex = Math.max(Math.abs(v0.x1 - v0.x0), 1e-300) * 1e-9; -const ey = Math.max(Math.abs(v0.y1 - v0.y0), 1e-300) * 1e-9; -const atHome = -Math.min(view.x0, view.x1) <= v0.x0 + ex && Math.max(view.x0, view.x1) >= v0.x1 - ex && -Math.min(view.y0, view.y1) <= v0.y0 + ey && Math.max(view.y0, view.y1) >= v0.y1 - ey; -if (atHome) { -if (g.density !== g._homeDensity) { -const hd = g._homeDensity; -this._applySampleRebinGrid(g, { -...hd, -tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1), -}, false); -} -return; -} -if (this._sampleRebinDisabled) return; -if (!this._rebinWorker) { -this._rebinWorker = fcCreateRebinWorker(); -if (!this._rebinWorker) { -this._sampleRebinDisabled = true; -return; -} -this._rebinWorker.onmessage = (e) => this._onRebinResult(e.data); -this._rebinInit = new Set(); -} -if (!this._rebinInit.has(g.trace.id)) { -const cpu = g.sampleOverlay._cpu; -const n = Math.min(cpu.x.length, cpu.y.length); -const xs = new Float64Array(n); -const ys = new Float64Array(n); -for (let i = 0; i < n; i++) { -xs[i] = this._decodeValue(cpu.x, cpu.xMeta, i); -ys[i] = this._decodeValue(cpu.y, cpu.yMeta, i); -} -this._rebinWorker.postMessage( -{ type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer }, -[xs.buffer, ys.buffer] -); -this._rebinInit.add(g.trace.id); -} -this._rebinWorker.postMessage({ -type: "rebin", trace: g.trace.id, seq, -x0: Math.min(view.x0, view.x1), x1: Math.max(view.x0, view.x1), -y0: Math.min(view.y0, view.y1), y1: Math.max(view.y0, view.y1), -w: Math.max(16, Math.min(2048, Math.round(this.plot.w))), -h: Math.max(16, Math.min(2048, Math.round(this.plot.h))), -}); -}, -_onRebinResult(msg) { -if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; -const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); -if (!g) return; -const grid = new Float32Array(msg.grid); -this._applySampleRebinGrid(g, { -w: msg.w, h: msg.h, max: msg.max, normMax: msg.max, -colormap: g.density.colormap, -xRange: [msg.x0, msg.x1], yRange: [msg.y0, msg.y1], -grid, -tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1), -lut: g.density.lut, -}, true); -}, -_applySampleRebinGrid(g, density, rebinned) { -g.prevDensity = g.density; -g._densityFadeStart = this._now(); -g.densityNormMax = density.normMax || density.max; -g.density = density; -g._sampleRebinned = !!rebinned; -lodRememberDensity(this, g, g.density); -this._refreshReductionBadges(); -this.draw(); -}, -_applyAppend(msg, buffers) { -const spec = msg.spec; -const blobRaw = buffers && buffers[0]; -if (!spec || !blobRaw || !spec.traces) return; -const blob = bytesToSpan(blobRaw); -const spanEps = (lo, hi) => Math.max(Math.abs(hi - lo), 1e-300) * 1e-9; -const ex = spanEps(this.view0.x0, this.view0.x1); -const ey = spanEps(this.view0.y0, this.view0.y1); -const atHome = -Math.abs(this.view.x0 - this.view0.x0) <= ex && Math.abs(this.view.x1 - this.view0.x1) <= ex && -Math.abs(this.view.y0 - this.view0.y0) <= ey && Math.abs(this.view.y1 - this.view0.y1) <= ey; -const pinnedRight = !atHome && Math.abs(this.view.x1 - this.view0.x1) <= ex; -this.spec = spec; -this.axes = this._normalizeAxes(spec); -this._payload = blob; -this.view0 = { -x0: spec.x_axis.range[0], x1: spec.x_axis.range[1], -y0: spec.y_axis.range[0], y1: spec.y_axis.range[1], -}; -if (atHome) { -this.view = { ...this.view0 }; -} else if (pinnedRight) { -const w = this.view.x1 - this.view.x0; -this.view = { ...this.view, x1: this.view0.x1, x0: this.view0.x1 - w }; -} -if (this._glLost || !this.gl) return; -const texSeen = new Set(); -for (const id of msg.affected || []) { -const i = this.gpuTraces.findIndex((g) => g.trace.id === id); -const ts = spec.traces.find((t) => t.id === id); -if (i < 0 || !ts) continue; -this._destroyTraceResources(this.gpuTraces[i], texSeen); -this.gpuTraces[i] = this._buildTrace(blob, ts); -} -this._pickable = this.gpuTraces.some( -(g) => markOf(g.trace.kind).pointPick && (g.tier !== "density" || g.drill)); -if (this._pickable && !this.pickFbo) this._initPickTarget(); -this._scheduleViewRequest(this.view, { delay: 0 }); -this.draw(); -}, -_onKernelMsg(msg, buffers) { -if (this._destroyed) return; -if (!msg) return; -if (this._glLost && msg.type !== "append" && msg.type !== "pick_result") return; -if (msg.type === "tier_update") { -if (msg.seq !== this.seq) return; -for (const upd of msg.traces) { -const g = this.gpuTraces.find((t) => t.trace.id === upd.id); -if (!g) continue; -const gl = this.gl; -const xArr = this._asF32(buffers[upd.x.buf]); -const yArr = this._asF32(buffers[upd.y.buf]); -const bArr = upd.base && g.baseBuf ? this._asF32(buffers[upd.base.buf]) : null; -let n = Math.min(upd.x.len, upd.y.len); -if (bArr) n = Math.min(n, upd.base.len); -const sm = this._smoothArrays(g.trace, xArr, yArr, bArr, n); -const src = sm || { x: xArr, y: yArr, n }; -const st = this._stepArrays(g.trace, src.x, src.y, src.n); -gl.bindBuffer(gl.ARRAY_BUFFER, g.xBuf); -gl.bufferData(gl.ARRAY_BUFFER, st ? st.x : src.x, gl.STATIC_DRAW); -gl.bindBuffer(gl.ARRAY_BUFFER, g.yBuf); -gl.bufferData(gl.ARRAY_BUFFER, st ? st.y : src.y, gl.STATIC_DRAW); -g.xMeta = { ...g.xMeta, offset: upd.x.offset, scale: upd.x.scale }; -g.yMeta = { ...g.yMeta, offset: upd.y.offset, scale: upd.y.scale }; -g._dashX = st ? st.x : src.x; -g._dashY = st ? st.y : src.y; -if (bArr) { -gl.bindBuffer(gl.ARRAY_BUFFER, g.baseBuf); -gl.bufferData(gl.ARRAY_BUFFER, sm ? sm.extra : bArr, gl.STATIC_DRAW); -g.baseMeta = { ...g.baseMeta, offset: upd.base.offset, scale: upd.base.scale }; -} -g.n = st ? st.n : src.n; -} -this.draw(); -} else if (msg.type === "density_update") { -if (msg.seq !== undefined && msg.seq !== this.seq) return; -const densityTraces = msg.traces || []; -const pendingTraceIds = new Set(densityTraces.map((upd) => Number(upd.id))); -if (pendingTraceIds.size === 0 && msg.trace !== undefined) { -pendingTraceIds.add(Number(msg.trace)); -} -const clearAllPending = pendingTraceIds.size === 0 && msg.stale; -const clearPending = (g) => { -if (msg.seq !== undefined && g._lodPendingSeq !== msg.seq) return; -g._lodPendingView = null; -g._lodPendingSeq = null; -g._lodPendingAt = null; -}; -if (pendingTraceIds.size || clearAllPending) { -for (const g of this.gpuTraces) { -if (g.tier !== "density") continue; -if (!clearAllPending && !pendingTraceIds.has(g.trace.id)) continue; -clearPending(g); -} -} -for (const upd of densityTraces) { -const g = this.gpuTraces.find((t) => t.trace.id === upd.id && t.tier === "density"); -if (!g) continue; -clearPending(g); -if (upd.mode === "points") { this._applyDrill(g, upd, buffers); continue; } -lodApplyDensityUpdate(this, g, upd, buffers); -} -this._pickable = this.gpuTraces.some( -(t) => markOf(t.trace.kind).pointPick && (t.tier !== "density" || t.drill)); -if (this._pickable && !this.pickFbo) this._initPickTarget(); -this.draw(); -} else if (msg.type === "append") { -this._applyAppend(msg, buffers); -} else if (msg.type === "pick_result") { -if (msg.seq !== undefined && msg.seq !== this._pickSeq) return; -if (!msg.row) { this.tooltip.style.display = "none"; return; } -this._lastRow = msg.row; -const xy = this._lastHoverXY; -if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY, { -announce: !this._a11yKeyboardReadout, -}); -if (this._interactionFlag("hover")) { -this._dispatchChartEvent("hover", { -row: msg.row, -trace: msg.row.trace, -index: msg.row.index, -exact: true, -view: this._eventView("hover"), -}); -} -} else if (msg.type === "selection") { -if (!msg.traces || !msg.traces.length) { -for (const g of this.gpuTraces) { -g.selActive = false; -if (g.drill) g.drill.selActive = false; -} -} else { -for (const upd of msg.traces) { -const g = this.gpuTraces.find((t) => t.trace.id === upd.id); -if (!g) continue; -const pg = g.tier === "density" ? g.drill : g; -if (!pg || !pg.n) continue; -if ( -g.tier === "density" && upd.drill_seq !== undefined && -pg.seq !== undefined && upd.drill_seq !== pg.seq -) continue; -const idx = this._asU32(buffers[upd.buf]); -const mask = new Float32Array(pg.n); -for (let i = 0; i < idx.length; i++) if (idx[i] < pg.n) mask[idx[i]] = 1; -this._applySelMask(pg, mask); -} -} -this._selectionCount = msg.total || 0; -this.draw(); -if (this._interactionFlag("select", true)) { -this._dispatchChartEvent("select", { -total: this._selectionCount, -view: this._eventView("select"), -}); -} -} -}, -_applyDrill(g, upd, buffers) { -lodApplyDrill(this, g, upd, buffers); -}, -_dropDrill(g) { -lodDropDrill(this, g); -}, -_viewInside(win) { -if (!win) return false; -const { x0, x1, y0, y1 } = this.view; -const ex = Math.abs(x1 - x0) * 1e-4, ey = Math.abs(y1 - y0) * 1e-4; -const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); -const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); -const wx0 = Math.min(win.x0, win.x1), wx1 = Math.max(win.x0, win.x1); -const wy0 = Math.min(win.y0, win.y1), wy1 = Math.max(win.y0, win.y1); -return vx0 >= wx0 - ex && vx1 <= wx1 + ex && vy0 >= wy0 - ey && vy1 <= wy1 + ey; -}, -_viewInsideRange(xRange, yRange) { -if (!xRange || !yRange) return false; -return this._viewInside({ x0: xRange[0], x1: xRange[1], y0: yRange[0], y1: yRange[1] }); -}, -}); -const RECT_MARK = { -build: (view, g, t, buffer) => view._buildRectMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -const edgePad = g.trace.kind === "histogram" -? [0, 0, view._edgePadForValue(0, y0, y1, view.canvas.height), 0] -: [0, 0, 0, 0]; -view._drawRects( -g, -view._map(g.x0Meta, x0, x1, g.xAxis), -view._map(g.x1Meta, x0, x1, g.xAxis), -view._map(g.y0Meta, y0, y1, g.yAxis), -view._map(g.y1Meta, y0, y1, g.yAxis), -edgePad -); -}, -refreshColor: (view, g) => { -if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); -view._rectMarkStyleGpu(g, g.trace); -}, -}; -const BAR_MARK = { -build: (view, g, t, buffer) => view._buildBarMark(g, t, buffer), -draw: (view, g) => { -if (!g.trace.bar) { -RECT_MARK.draw(view, g); -return; -} -const horizontal = g.orientation === 1; -const pAxis = horizontal ? g.yAxis : g.xAxis; -const vAxis = horizontal ? g.xAxis : g.yAxis; -const [p0, p1] = view._axisRange(pAxis); -const [v0, v1] = view._axisRange(vAxis); -const pmap = view._map(g.posMeta, p0, p1, pAxis); -const v1map = view._map(g.value1Meta, v0, v1, vAxis); -const v0map = g.value0Mode === 1 -? view._map(g.value0Meta, v0, v1, vAxis) -: null; -const v0Const = g.value0Mode === 0 -? view._mapConst(g.value0Const, v0, v1, vAxis) -: null; -const v0EdgePad = g.value0Mode === 0 -? view._edgePadForValue( -g.value0Const, -v0, -v1, -horizontal ? view.canvas.width : view.canvas.height -) -: 0; -view._drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad); -}, -refreshColor: (view, g) => { -if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); -view._rectMarkStyleGpu(g, g.trace); -}, -}; -const SEGMENT_MARK = { -build: (view, g, t, buffer) => view._buildSegmentMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -view._drawSegments( -g, -view._map(g.x0Meta, x0, x1, g.xAxis), -view._map(g.y0Meta, y0, y1, g.yAxis), -); -}, -refreshColor: (view, g) => { -if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); -}, -}; -const AREA_MARK = { -build: (view, g, t, buffer) => view._buildAreaMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -const xm = view._map(g.xMeta, x0, x1, g.xAxis); -const ym = view._map(g.yMeta, y0, y1, g.yAxis); -view._drawArea(g, xm, ym, view._map(g.baseMeta, y0, y1, g.yAxis)); -if ((g.trace.style.line_width ?? 0) > 0) { -view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); -if (g.trace.style.stroke_perimeter) { -const yBuf = g.yBuf, yMeta = g.yMeta, dashY = g._dashY; -g.yBuf = g.baseBuf; -g.yMeta = g.baseMeta; -g._dashY = g._cpu.base; -view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); -g.yBuf = yBuf; -g.yMeta = yMeta; -g._dashY = dashY; -} -} -}, -refreshColor: (view, g) => { -g.color = parseColor(view.root, g.trace.style.color, g.color); -g.lineColor = parseColor(view.root, g.trace.style.line_color || g.trace.style.color, g.lineColor || g.color); -g.grad = view._resolveMarkFill(g.trace.style, g.color); -}, -}; -const MESH_MARK = { -build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); -}, -refreshColor: (view, g) => { -if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); -const style = g.trace.style || {}; -g.meshStroke = parseColor(view.root, style.stroke || "transparent", [0, 0, 0, 0]); -}, -}; -const MARK_KINDS = { -histogram: RECT_MARK, -box: RECT_MARK, -violin: RECT_MARK, -errorbar: SEGMENT_MARK, -stem: SEGMENT_MARK, -box_whisker: SEGMENT_MARK, -box_median: SEGMENT_MARK, -contour: SEGMENT_MARK, -segments: SEGMENT_MARK, -triangle_mesh: MESH_MARK, -error_band: AREA_MARK, -hexbin: { -build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); -}, -refreshColor: (view, g) => { -if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); -const style = g.trace.style || {}; -g.meshStroke = parseColor(view.root, style.stroke || "transparent", [0, 0, 0, 0]); -}, -}, -bar: BAR_MARK, -column: BAR_MARK, -heatmap: { -build: (view, g, t, buffer) => view._buildHeatmapMark(g, t, buffer), -draw: (view, g) => view._drawHeatmap(g), -}, -scatter: { -build: (view, g, t, buffer) => view._buildScatterMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -view._drawPoints(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); -}, -pointPick: true, -retainCpu: true, -refreshColor: (view, g) => { -if (g.colorMode === 0 && g.trace.color) { -g.color = parseColor(view.root, g.trace.color.color, g.color); -} -view._pointMarkStyle(g, g.trace); -}, -}, -line: { -build: (view, g, t, buffer) => view._buildLineMark(g, t, buffer), -draw: (view, g) => { -const [x0, x1] = view._axisRange(g.xAxis); -const [y0, y1] = view._axisRange(g.yAxis); -view._drawLine(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); -}, -refreshColor: (view, g) => { -g.color = parseColor(view.root, g.trace.style.color, g.color); -}, -}, -area: AREA_MARK, -}; -function markOf(kind) { -return MARK_KINDS[kind] || MARK_KINDS.scatter; -} -function bytesToSpan(b) { -const span = fcByteSpan(b, "chart payload"); -return span.byteOffset % 4 === 0 ? span : new Uint8Array(span); -} - -function payloadBuffers(spec, raw) { -if (spec.buffer_layout === "split") { -if (!Array.isArray(raw)) { -throw new Error("xy: spec says buffer_layout=split but the transport delivered one buffer"); -} -return raw.map(bytesToSpan); -} -if (Array.isArray(raw)) { -throw new Error("xy: transport delivered a buffer list but the spec is not split-layout"); -} -return bytesToSpan(raw); -} -function render({ model, el }) { -const spec = model.get("spec"); -const buffer = payloadBuffers(spec, model.get("buffers")); -const comm = { -send: (msg) => model.send(msg), -onMessage: (cb) => { -const handler = (content, buffers) => cb(content, buffers); -model.on("msg:custom", handler); -return () => model.off?.("msg:custom", handler); -}, -}; -const view = new ChartView(el, spec, buffer, comm); -return () => view.destroy(); -} - -function renderStandalone(el, spec, arrayBuffer) { -const buffer = bytesToSpan(arrayBuffer); -const view = new ChartView(el, spec, buffer, null); -const column = (idx) => view._columnView(buffer, spec.columns[idx]); -for (const g of view.gpuTraces) { -if (markOf(g.trace.kind).retainCpu && g.tier !== "density") { -g._cpu = { -x: column(g.trace.x), -y: column(g.trace.y), -xMeta: g.xMeta, -yMeta: g.yMeta, -}; -if (g.trace.color && Number.isInteger(g.trace.color.buf)) { -g._cpu.color = column(g.trace.color.buf); -} -if (g.trace.size && Number.isInteger(g.trace.size.buf)) { -g._cpu.size = column(g.trace.size.buf); -} -} -} -return view; -} - -export { render, renderStandalone, decodeFrame, ChartView, MARK_KINDS, markOf }; -export default { render, decodeFrame }; diff --git a/tests/reflex_adapter/test_assets.py b/tests/reflex_adapter/test_assets.py index f70a5507..8daa65e8 100644 --- a/tests/reflex_adapter/test_assets.py +++ b/tests/reflex_adapter/test_assets.py @@ -1,23 +1,50 @@ -"""Shipped frontend assets: parity with the canonical bundle + wrapper contract.""" +"""Shipped frontend assets: client sourced from the xy install + wrapper contract.""" from __future__ import annotations import pathlib import reflex_xy +from reflex_xy.assets import _client_source, _link_client + +import xy ADAPTER_ASSETS = pathlib.Path(reflex_xy.__file__).parent / "assets" -CANONICAL = pathlib.Path(__file__).resolve().parents[2] / "python" / "xy" / "static" -def test_client_copy_matches_canonical_bundle(): - """xy_client.js is a build artifact: byte-identical to static/index.js. +def test_client_is_not_packaged(): + """No second copy of the render client exists to drift: the adapter links + the installed xy bundle at app compile time.""" + assert not (ADAPTER_ASSETS / "xy_client.js").exists() + + +def test_client_source_is_the_installed_bundle(): + source = _client_source() + assert source == pathlib.Path(xy.__file__).resolve().parent / "static" / "index.js" + text = source.read_text(encoding="utf-8") + for marker in ("function renderStandalone(", "function decodeFrame(", "class ChartView"): + assert marker in text + + +def test_link_client_creates_and_repairs(tmp_path): + asset_root = tmp_path / "assets" + _link_client(asset_root) + dst = asset_root / "external" / "reflex_xy" / "assets" / "xy_client.js" + assert dst.is_symlink() + assert dst.resolve() == _client_source() + + # idempotent + _link_client(asset_root) + assert dst.resolve() == _client_source() - On drift: run `node js/build.mjs` and commit both copies. - """ - adapter = (ADAPTER_ASSETS / "xy_client.js").read_bytes() - canonical = (CANONICAL / "index.js").read_bytes() - assert adapter == canonical + # a stale link (xy reinstalled elsewhere, venv moved) gets repaired, + # unlike rx.asset's fixed-location shared files + dst.unlink() + imposter = tmp_path / "old_install.js" + imposter.write_text("stale") + dst.symlink_to(imposter) + _link_client(asset_root) + assert dst.resolve() == _client_source() def test_wrapper_speaks_the_namespace_protocol():