diff --git a/examples/reflex/README.md b/examples/reflex/README.md index f68bfc0b..eedc8ef5 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -1,8 +1,9 @@ # reflex-xy showcase A [Reflex](https://reflex.dev) app built on the -[`reflex-xy`](../../python/reflex-xy) adapter. One page walks through the ways -to link chart data into a Reflex app, and each section carries a **Code** +[`reflex-xy`](../../python/reflex-xy) adapter. Two pages: `/` walks through +the ways to link chart data into a Reflex app, and `/flights` applies the +same patterns to live real-world data. Each section carries a **Code** accordion showing its source via `inspect.getsource`. Chart data rides the app's own websocket as a second socket.io namespace of @@ -24,6 +25,41 @@ binary columns; Reflex state holds only a token string per chart. (static payload tier) and a `reflex_xy.inline` token (fixed data served through the kernel). +## `/flights` — every aircraft on Earth, live + +[`xy_reflex_demo/flights.py`](xy_reflex_demo/flights.py) is the same pattern +set as a throughput showcase on real sensor data. By default a background +task polls OpenSky's anonymous `/states/all` for the **whole planet** +(~12–15k aircraft) and republishes a figure var each cycle — positions +colored by altitude (turbo), sized by ground speed, with per-aircraft +trails, over the full Natural Earth 50m coastline + borders (~80k points of +`xy.segments`). Every cycle rebuilds and re-ships the entire figure as +binary columns (a single multi-MB blob on the wire) while pan/zoom stays +smooth. Clicking an aircraft follows it (altitude trail chart); shift-drag +box-selects a region and cross-filters an altitude histogram. + +`XY_FLIGHTS_MODE=region` switches to the +[adsb.fi](https://github.com/adsbfi/opendata) open-data API: one 250 nm +circle (default: central Europe, ~900 aircraft), pollable down to 1 s. + +If the live API is unreachable the page falls back to bundled real captures +and always opens animated — including fully offline. Region mode cycles ten +recorded frames; world mode dead-reckons a single 14k-aircraft OpenSky +capture forward along each aircraft's track at its ground speed (~270 KB +asset instead of megabytes of frames). +`xy_reflex_demo/data/regenerate.py` (stdlib-only) rebuilds all bundled +assets: `--world` for the global pair, `--center LAT,LON` to re-center the +regional pair (mirrored at runtime by `XY_FLIGHTS_CENTER`). + +`XY_FLIGHTS_POLL` sets the live poll cadence in seconds. World default is 15 +(OpenSky bills anonymous callers 4 credits per global snapshot from a +400/day budget); region default is 3, floor 1 (adsb.fi's documented limit is +1 request/second and its feed refreshes about once a second): + +```bash +XY_FLIGHTS_MODE=region XY_FLIGHTS_POLL=1 uv run reflex run +``` + ## Run ```bash diff --git a/examples/reflex/xy_reflex_demo/data/basemap_eu.json.gz b/examples/reflex/xy_reflex_demo/data/basemap_eu.json.gz new file mode 100644 index 00000000..1493cbbd Binary files /dev/null and b/examples/reflex/xy_reflex_demo/data/basemap_eu.json.gz differ diff --git a/examples/reflex/xy_reflex_demo/data/basemap_world.json.gz b/examples/reflex/xy_reflex_demo/data/basemap_world.json.gz new file mode 100644 index 00000000..cc44af27 Binary files /dev/null and b/examples/reflex/xy_reflex_demo/data/basemap_world.json.gz differ diff --git a/examples/reflex/xy_reflex_demo/data/regenerate.py b/examples/reflex/xy_reflex_demo/data/regenerate.py new file mode 100644 index 00000000..4d5bddcb --- /dev/null +++ b/examples/reflex/xy_reflex_demo/data/regenerate.py @@ -0,0 +1,183 @@ +"""Rebuild the flights page's bundled assets (stdlib only). + +* ``basemap_world.json.gz`` / ``basemap_eu.json.gz`` — Natural Earth 50m + coastline + land-border polylines (whole world at 2 decimals / regional + clip at 3 decimals). +* ``replay_world.json.gz`` — ONE full OpenSky ``/states/all`` capture; the + app dead-reckons it forward for offline motion. +* ``replay_eu.json.gz`` — ten real ADS-B frames captured from adsb.fi. + +Run from anywhere:: + + python3 examples/reflex/xy_reflex_demo/data/regenerate.py --world + python3 examples/reflex/xy_reflex_demo/data/regenerate.py \ + [--center LAT,LON] [--radius NM] [--frames N] [--interval SECONDS] + +Re-centering (e.g. ``--center 40.64,-73.78`` for the US east coast) rebuilds +the regional assets for that region; set ``XY_FLIGHTS_CENTER`` to the same +value when running the app (with ``XY_FLIGHTS_MODE=region``). +""" + +from __future__ import annotations + +import argparse +import gzip +import json +import time +import urllib.request +from pathlib import Path + +DATA_DIR = Path(__file__).parent +NE_BASE = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson" +NE_LAYERS = { + "coast": "ne_50m_coastline.geojson", + "borders": "ne_50m_admin_0_boundary_lines_land.geojson", +} + + +def _get(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "xy-reflex-example"}) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read() + + +def _clip(geojson: dict, bbox: tuple[float, float, float, float]) -> list[list[list[float]]]: + lon0, lat0, lon1, lat1 = bbox + polys: list[list[list[float]]] = [] + for feat in geojson["features"]: + g = feat["geometry"] + if g["type"] not in ("LineString", "MultiLineString"): + continue + lines = [g["coordinates"]] if g["type"] == "LineString" else g["coordinates"] + for coords in lines: + cur: list[list[float]] = [] + for lon, lat in coords: + if lon0 <= lon <= lon1 and lat0 <= lat <= lat1: + cur.append([round(lon, 3), round(lat, 3)]) + else: + if len(cur) > 1: + polys.append(cur) + cur = [] + if len(cur) > 1: + polys.append(cur) + return polys + + +def build_basemap(center: tuple[float, float]) -> None: + lat, lon = center + # view box (±7° lat, ±21° lon) plus a margin so pans keep coastline + bbox = (lon - 22.5, lat - 9.0, lon + 22.5, lat + 9.0) + out = {"bbox": [bbox[0], bbox[1], bbox[2], bbox[3]]} + for kind, name in NE_LAYERS.items(): + print(f"downloading {name} ...") + out[kind] = _clip(json.loads(_get(f"{NE_BASE}/{name}")), bbox) + print(f" {kind}: {len(out[kind])} polylines") + path = DATA_DIR / "basemap_eu.json.gz" + path.write_bytes(gzip.compress(json.dumps(out, separators=(",", ":")).encode())) + print(f"wrote {path} ({path.stat().st_size:,} bytes)") + + +def build_replay(center: tuple[float, float], radius: int, frames: int, interval: float) -> None: + lat, lon = center + url = f"https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{radius}" + captured = [] + for i in range(frames): + if i: + time.sleep(interval) + payload = json.loads(_get(url)) + cols: dict[str, list] = { + k: [] for k in ("hex", "flight", "type", "lat", "lon", "alt", "gs", "track") + } + for a in payload.get("aircraft", []): + if a.get("lat") is None or a.get("lon") is None: + continue + alt = a.get("alt_baro") + cols["hex"].append(a.get("hex", "")) + cols["flight"].append((a.get("flight") or "").strip()) + cols["type"].append(a.get("t", "")) + cols["lat"].append(round(float(a["lat"]), 4)) + cols["lon"].append(round(float(a["lon"]), 4)) + cols["alt"].append(float(alt) if isinstance(alt, (int, float)) else 0.0) + cols["gs"].append(round(float(a.get("gs") or 0.0), 1)) + cols["track"].append(round(float(a.get("track") or 0.0), 1)) + captured.append({"now": payload.get("now"), **cols}) + print(f"frame {i}: {len(cols['hex'])} aircraft") + out = {"center": [lat, lon], "radius_nm": radius, "frames": captured} + path = DATA_DIR / "replay_eu.json.gz" + path.write_bytes(gzip.compress(json.dumps(out, separators=(",", ":")).encode())) + print(f"wrote {path} ({path.stat().st_size:,} bytes)") + + +def build_world() -> None: + """World basemap (2dp, lat ±85) + one OpenSky global capture.""" + out: dict = {"bbox": [-180, -85, 180, 85]} + for kind, name in NE_LAYERS.items(): + print(f"downloading {name} ...") + geo = json.loads(_get(f"{NE_BASE}/{name}")) + polys = [] + for feat in geo["features"]: + g = feat["geometry"] + if g["type"] not in ("LineString", "MultiLineString"): + continue + lines = [g["coordinates"]] if g["type"] == "LineString" else g["coordinates"] + for coords in lines: + cur = [] + for lon, lat in coords: + if -85.0 <= lat <= 85.0: + cur.append([round(lon, 2), round(lat, 2)]) + else: + if len(cur) > 1: + polys.append(cur) + cur = [] + if len(cur) > 1: + polys.append(cur) + out[kind] = polys + print(f" {kind}: {len(polys)} polylines") + path = DATA_DIR / "basemap_world.json.gz" + path.write_bytes(gzip.compress(json.dumps(out, separators=(",", ":")).encode())) + print(f"wrote {path} ({path.stat().st_size:,} bytes)") + + print("capturing OpenSky /states/all ...") + payload = json.loads(_get("https://opensky-network.org/api/states/all")) + cols: dict[str, list] = { + k: [] for k in ("hex", "flight", "type", "lat", "lon", "alt", "gs", "track") + } + for s in payload.get("states") or []: + lon, lat = s[5], s[6] + if lon is None or lat is None: + continue + on_ground, alt_m = bool(s[8]), s[7] + cols["hex"].append(s[0] or "") + cols["flight"].append((s[1] or "").strip()) + cols["type"].append("") + cols["lat"].append(round(float(lat), 3)) + cols["lon"].append(round(float(lon), 3)) + cols["alt"].append( + 0.0 if on_ground or not isinstance(alt_m, (int, float)) else round(alt_m * 3.28084) + ) + cols["gs"].append(round(float(s[9] or 0.0) * 1.94384, 1)) + cols["track"].append(round(float(s[10] or 0.0), 1)) + out = {"source": "opensky /states/all", "time": payload.get("time"), "frames": [cols]} + path = DATA_DIR / "replay_world.json.gz" + path.write_bytes(gzip.compress(json.dumps(out, separators=(",", ":")).encode())) + print(f"wrote {path} ({len(cols['hex'])} aircraft, {path.stat().st_size:,} bytes)") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--world", action="store_true", help="rebuild the world-mode assets") + ap.add_argument("--center", default="50.03,8.57", help="LAT,LON of the query circle") + ap.add_argument("--radius", type=int, default=250, help="query radius in nm (max 250)") + ap.add_argument("--frames", type=int, default=10, help="replay frames to capture") + ap.add_argument("--interval", type=float, default=6.0, help="seconds between frames") + args = ap.parse_args() + if args.world: + build_world() + return + lat, lon = (float(v) for v in args.center.split(",")) + build_basemap((lat, lon)) + build_replay((lat, lon), args.radius, args.frames, args.interval) + + +if __name__ == "__main__": + main() diff --git a/examples/reflex/xy_reflex_demo/data/replay_eu.json.gz b/examples/reflex/xy_reflex_demo/data/replay_eu.json.gz new file mode 100644 index 00000000..6cd7f8e4 Binary files /dev/null and b/examples/reflex/xy_reflex_demo/data/replay_eu.json.gz differ diff --git a/examples/reflex/xy_reflex_demo/data/replay_world.json.gz b/examples/reflex/xy_reflex_demo/data/replay_world.json.gz new file mode 100644 index 00000000..d2d2fe8b Binary files /dev/null and b/examples/reflex/xy_reflex_demo/data/replay_world.json.gz differ diff --git a/examples/reflex/xy_reflex_demo/flights.py b/examples/reflex/xy_reflex_demo/flights.py new file mode 100644 index 00000000..f303be79 --- /dev/null +++ b/examples/reflex/xy_reflex_demo/flights.py @@ -0,0 +1,557 @@ +"""Live flight tracker: every airborne aircraft on Earth, in a Reflex app. + +Default (``XY_FLIGHTS_MODE=world``): a background task polls OpenSky's +anonymous ``/states/all`` for the global picture (~12-15k aircraft) and +republishes an ``@reflex_xy.figure`` scatter each cycle — positions colored +by altitude, sized by ground speed, with per-aircraft trails, over the whole +Natural Earth 50m coastline + borders (~80k points) drawn with +``xy.segments``. Every cycle re-encodes and re-ships the full figure +(hundreds of thousands of segments, several MB of binary columns) while the +client keeps 60fps pan/zoom — that throughput is the point of the page. +Clicking an aircraft follows it; box-selecting cross-filters the histogram. + +``XY_FLIGHTS_MODE=region`` switches to the adsb.fi open-data API: one 250 nm +circle (default: central Europe, ~900 aircraft), 1-second-capable cadence. + +If the live API is unreachable the page falls back to bundled real captures: +region mode cycles ten recorded frames; world mode dead-reckons a single +14k-aircraft OpenSky capture forward along each aircraft's track at its +ground speed, so the world keeps moving with a ~270KB asset. +``data/regenerate.py`` rebuilds every bundled asset. + +The latest frame and per-aircraft trails live in module globals keyed only by +the ``frame_rev`` state var — figure builders stay cheap and state stays a +few scalars. A fresh backend worker that has never polled falls back to the +replay seed, so rebuild-on-miss stays deterministic. +""" + +from __future__ import annotations + +import asyncio +import gzip +import itertools +import json +import os +import urllib.request +from functools import lru_cache +from pathlib import Path + +import numpy as np +import reflex as rx +import reflex_xy + +import xy + +from .ui import code_accordion, kv, nav, section + +DATA_DIR = Path(__file__).parent / "data" + +# "world" = OpenSky /states/all, the whole planet at once. "region" = adsb.fi, +# one 250 nm circle at up-to-1s cadence. +WORLD = os.environ.get("XY_FLIGHTS_MODE", "world") != "region" + +# Region-mode query circle (adsb.fi caps the radius at 250 nm). Override via +# env, e.g. XY_FLIGHTS_CENTER="40.64,-73.78" for JFK — then regenerate the +# bundled basemap/replay for that region with data/regenerate.py. +_center = os.environ.get("XY_FLIGHTS_CENTER", "50.03,8.57").split(",") +CENTER_LAT, CENTER_LON = float(_center[0]), float(_center[1]) +RADIUS_NM = int(os.environ.get("XY_FLIGHTS_RADIUS", "250")) +REGION_URL = f"https://opendata.adsb.fi/api/v2/lat/{CENTER_LAT}/lon/{CENTER_LON}/dist/{RADIUS_NM}" +WORLD_URL = "https://opensky-network.org/api/states/all" + +# Cadence. Region: adsb.fi's documented limit is 1 request/second and its feed +# refreshes about once a second — XY_FLIGHTS_POLL=1 is the useful floor. +# World: OpenSky bills anonymous callers 4 credits per global snapshot from a +# 400/day budget (100 calls), so the default spends them slowly; on a 429 the +# page falls back to the dead-reckoned replay. +_default_poll = "15" if WORLD else "3" +POLL_SECONDS = max(1.0, float(os.environ.get("XY_FLIGHTS_POLL", _default_poll))) +REPLAY_SECONDS = 3.0 +# Positions kept per aircraft. World trails are shorter: ~14k aircraft x +# trail segments x 5 columns is the dominant share of each republish. +TRAIL_LEN = 12 if WORLD else 24 +ALT_DOMAIN = (0.0, 45_000.0) # ft; fixed so colors are stable across frames + +# View window. Region: ±7° latitude, ±21° longitude — 3:1 to roughly offset +# equirectangular stretch at 50°N in a wide chart. World: everything between +# the polar dead zones. +VIEW_LAT = (-60.0, 75.0) if WORLD else (CENTER_LAT - 7.0, CENTER_LAT + 7.0) +VIEW_LON = (-180.0, 180.0) if WORLD else (CENTER_LON - 21.0, CENTER_LON + 21.0) +BASEMAP_FILE = "basemap_world.json.gz" if WORLD else "basemap_eu.json.gz" +PLACE = "worldwide" if WORLD else "over central Europe" + + +# --- data sources ----------------------------------------------------------- + + +def _columns(aircraft: list[dict]) -> dict: + """tar1090-style aircraft list -> columnar frame. ``alt_baro`` is the + string ``"ground"`` for taxiing aircraft; those become 0 ft.""" + cols: dict[str, list] = { + k: [] for k in ("hex", "flight", "type", "lat", "lon", "alt", "gs", "track") + } + for a in aircraft: + lat, lon = a.get("lat"), a.get("lon") + if lat is None or lon is None: + continue + alt = a.get("alt_baro") + cols["hex"].append(a.get("hex", "")) + cols["flight"].append((a.get("flight") or "").strip()) + cols["type"].append(a.get("t", "")) + cols["lat"].append(float(lat)) + cols["lon"].append(float(lon)) + cols["alt"].append(float(alt) if isinstance(alt, (int, float)) else 0.0) + cols["gs"].append(float(a.get("gs") or 0.0)) + cols["track"].append(float(a.get("track") or 0.0)) + return { + "hex": cols["hex"], + "flight": cols["flight"], + "type": cols["type"], + "lat": np.asarray(cols["lat"]), + "lon": np.asarray(cols["lon"]), + "alt": np.asarray(cols["alt"]), + "gs": np.asarray(cols["gs"]), + "track": np.asarray(cols["track"]), + } + + +def _world_columns(states: list[list]) -> dict: + """OpenSky ``/states/all`` rows -> columnar frame (SI units -> ft/kt).""" + cols: dict[str, list] = { + k: [] for k in ("hex", "flight", "type", "lat", "lon", "alt", "gs", "track") + } + for s in states: + lon, lat = s[5], s[6] + if lon is None or lat is None: + continue + on_ground, alt_m = bool(s[8]), s[7] + cols["hex"].append(s[0] or "") + cols["flight"].append((s[1] or "").strip()) + cols["type"].append("") # OpenSky states carry no airframe type + cols["lat"].append(float(lat)) + cols["lon"].append(float(lon)) + cols["alt"].append( + 0.0 if on_ground or not isinstance(alt_m, (int, float)) else float(alt_m) * 3.28084 + ) + cols["gs"].append(float(s[9] or 0.0) * 1.94384) + cols["track"].append(float(s[10] or 0.0)) + return { + "hex": cols["hex"], + "flight": cols["flight"], + "type": cols["type"], + "lat": np.asarray(cols["lat"]), + "lon": np.asarray(cols["lon"]), + "alt": np.asarray(cols["alt"]), + "gs": np.asarray(cols["gs"]), + "track": np.asarray(cols["track"]), + } + + +def fetch_live() -> dict: + """One live frame — the whole planet (world) or one circle (region).""" + url = WORLD_URL if WORLD else REGION_URL + req = urllib.request.Request(url, headers={"User-Agent": "xy-reflex-example"}) + with urllib.request.urlopen(req, timeout=15) as resp: + payload = json.load(resp) + if WORLD: + return _world_columns(payload.get("states") or []) + return _columns(payload.get("aircraft", [])) + + +def _decode_frames(name: str) -> list[dict]: + raw = json.loads(gzip.decompress((DATA_DIR / name).read_bytes())) + return [ + { + "hex": f["hex"], + "flight": f["flight"], + "type": f["type"], + "lat": np.asarray(f["lat"], dtype=np.float64), + "lon": np.asarray(f["lon"], dtype=np.float64), + "alt": np.asarray(f["alt"], dtype=np.float64), + "gs": np.asarray(f["gs"], dtype=np.float64), + "track": np.asarray(f["track"], dtype=np.float64), + } + for f in raw["frames"] + ] + + +@lru_cache(maxsize=1) +def _replay_frames() -> list[dict]: + return _decode_frames("replay_eu.json.gz") + + +@lru_cache(maxsize=1) +def _world_seed() -> dict: + return _decode_frames("replay_world.json.gz")[0] + + +def _replay_frame(i: int) -> dict: + """Offline frame ``i``. Region mode cycles ten recorded frames; world + mode dead-reckons the single bundled capture: each aircraft advances + along its recorded track at its recorded ground speed.""" + if not WORLD: + frames = _replay_frames() + return frames[i % len(frames)] + seed = _world_seed() + hours = (i * REPLAY_SECONDS) / 3600.0 + dist_deg = seed["gs"] * hours / 60.0 # kt -> nm -> degrees latitude + rad = np.radians(seed["track"]) + lat = seed["lat"] + dist_deg * np.cos(rad) + coslat = np.maximum(np.cos(np.radians(np.clip(lat, -85.0, 85.0))), 0.05) + lon = seed["lon"] + dist_deg * np.sin(rad) / coslat + lon = (lon + 180.0) % 360.0 - 180.0 + return {**seed, "lat": lat, "lon": lon} + + +@lru_cache(maxsize=1) +def _basemap() -> dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + """Natural Earth 50m coastline + land borders as segment endpoint arrays.""" + raw = json.loads(gzip.decompress((DATA_DIR / BASEMAP_FILE).read_bytes())) + out = {} + for kind in ("coast", "borders"): + x0, y0, x1, y1 = [], [], [], [] + for poly in raw[kind]: + pts = np.asarray(poly, dtype=np.float64) + x0.append(pts[:-1, 0]) + y0.append(pts[:-1, 1]) + x1.append(pts[1:, 0]) + y1.append(pts[1:, 1]) + out[kind] = tuple(np.concatenate(a) for a in (x0, y0, x1, y1)) + return out + + +# --- latest frame + trails (module state, keyed by the frame_rev var) ------- + +_latest: dict | None = None +_trails: dict[str, list[tuple[float, float, float]]] = {} # hex -> [(lon, lat, alt)] + + +def _current_frame() -> dict: + return _latest if _latest is not None else _replay_frame(0) + + +def _advance(frame: dict) -> None: + """Install a new frame and extend/prune the per-aircraft trails.""" + global _latest + _latest = frame + seen = set(frame["hex"]) + rows = zip(frame["hex"], frame["lon"], frame["lat"], frame["alt"], strict=True) + for h, lon, lat, alt in rows: + _trails.setdefault(h, []).append((float(lon), float(lat), float(alt))) + del _trails[h][:-TRAIL_LEN] + for h in [h for h in _trails if h not in seen]: + del _trails[h] + + +def _trail_segments() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + x0, y0, x1, y1, alt = [], [], [], [], [] + for pts in _trails.values(): + for (lon0, lat0, _), (lon1, lat1, a1) in itertools.pairwise(pts): + if abs(lon1 - lon0) > 90.0: # antimeridian hop; don't smear the map + continue + x0.append(lon0) + y0.append(lat0) + x1.append(lon1) + y1.append(lat1) + alt.append(a1) + return tuple(np.asarray(a) for a in (x0, y0, x1, y1, alt)) + + +# --- state ------------------------------------------------------------------ + + +class Flights(rx.State): + """Scalars only; the frame itself stays server-side in module globals.""" + + polling: bool = False + source: str = "idle" # "adsb.fi live" | "bundled replay" | "idle" + frame_rev: int = 0 + n_aircraft: int = 0 + followed_hex: str = "" + followed: dict = {} + sel_active: bool = False + sel_lon0: float = 0.0 + sel_lon1: float = 0.0 + sel_lat0: float = 0.0 + sel_lat1: float = 0.0 + sel_note: str = "box-select a region to filter the histogram" + + @reflex_xy.figure + def sky(self) -> xy.Chart: + _ = self.frame_rev # depend on the poll cycle + frame = _current_frame() + base = _basemap() + marks = [ + xy.segments(*base["coast"], color="#64748b", width=1.1, opacity=0.7), + xy.segments(*base["borders"], color="#94a3b8", width=0.9, opacity=0.55), + ] + tx0, ty0, tx1, ty1, talt = _trail_segments() + if tx0.size: + marks.append( + xy.segments( + tx0, + ty0, + tx1, + ty1, + color=talt, + colormap="turbo", + domain=ALT_DOMAIN, + width=1.0, + opacity=0.35, + ) + ) + marks.append( + xy.scatter( + frame["lon"], + frame["lat"], + color=frame["alt"], + colormap="turbo", + color_domain=ALT_DOMAIN, + size=frame["gs"], + size_range=(2.5, 7.0), + opacity=0.9, + density=False, + ) + ) + if self.followed_hex and self.followed_hex in frame["hex"]: + i = frame["hex"].index(self.followed_hex) + marks.append( + xy.scatter( + frame["lon"][i : i + 1], + frame["lat"][i : i + 1], + color="#f43f5e", + opacity=0.25, + size=14.0, + stroke="#f43f5e", + stroke_width=2.0, + ) + ) + return xy.scatter_chart( + *marks, + xy.interaction_config(hover=True, click=True), + xy.x_axis(label="longitude", domain=VIEW_LON), + xy.y_axis(label="latitude", domain=VIEW_LAT), + title=f"{self.n_aircraft} aircraft · {self.source}", + width="100%", + height=520, + ) + + @reflex_xy.figure + def altitudes(self) -> xy.Chart: + _ = self.frame_rev + frame = _current_frame() + alt, lon, lat = frame["alt"], frame["lon"], frame["lat"] + airborne = alt > 0.0 + if self.sel_active: + airborne &= ( + (lon >= self.sel_lon0) + & (lon <= self.sel_lon1) + & (lat >= self.sel_lat0) + & (lat <= self.sel_lat1) + ) + label = "selection" if self.sel_active else "all airborne" + return xy.histogram_chart( + xy.histogram(alt[airborne], bins=45, color="#7c3aed"), + xy.x_axis(label=f"barometric altitude, ft ({label})", format=",.0f"), + title=f"altitude distribution — {int(airborne.sum())} aircraft", + width="100%", + height=260, + ) + + @reflex_xy.figure + def follow(self) -> xy.Chart: + _ = self.frame_rev + trail = _trails.get(self.followed_hex, []) + if len(trail) < 2: + title = "click an aircraft on the map to follow it" + alt = np.array([0.0]) + t = np.array([0.0]) + else: + info = self.followed + title = ( + f"{info.get('callsign') or self.followed_hex} ({info.get('type') or '?'}) " + f"— altitude trail" + ) + alt = np.asarray([p[2] for p in trail]) + t = np.arange(len(trail), dtype=np.float64) - (len(trail) - 1) + return xy.line_chart( + xy.line(t, alt, color="#f43f5e", width=2.0), + xy.x_axis(label="poll cycles ago"), + xy.y_axis(label="altitude (ft)", format=",.0f"), + title=title, + width="100%", + height=260, + ) + + @rx.event + def on_click(self, event: reflex_xy.PointClickEvent): + # Match by nearest aircraft to the clicked f64 data coords instead of + # trusting trace/row bookkeeping across the multi-mark figure. + frame = _current_frame() + if not frame["hex"]: + return + data = event.get("data", {}) + lon, lat = float(data.get("x", 0.0)), float(data.get("y", 0.0)) + d2 = (frame["lon"] - lon) ** 2 + (frame["lat"] - lat) ** 2 + i = int(np.argmin(d2)) + if d2[i] > 0.5**2: # clicked empty sky / basemap + return + self.followed_hex = frame["hex"][i] + self.followed = { + "callsign": frame["flight"][i], + "type": frame["type"][i], + "alt": float(frame["alt"][i]), + "gs": float(frame["gs"][i]), + } + + @rx.event + def unfollow(self): + self.followed_hex = "" + self.followed = {} + + @rx.event + def on_select(self, event: reflex_xy.SelectEndEvent): + selection = event.get("selection", {}) + bounds = selection.get("data_bounds") or {} + if not selection.get("cleared") and bounds.get("x0") is not None: + self.sel_lon0 = float(bounds["x0"]) + self.sel_lon1 = float(bounds["x1"]) + self.sel_lat0 = float(bounds["y0"]) + self.sel_lat1 = float(bounds["y1"]) + self.sel_active = True + self.sel_note = f"{int(selection.get('total_count') or 0):,} aircraft in the box" + else: + self.sel_active = False + self.sel_note = "selection cleared" + + @rx.event(background=True) + async def poll(self): + """Toggleable poll loop: live API first, bundled replay on failure.""" + async with self: + if self.polling: + self.polling = False + self.source = "idle" + return + self.polling = True + replay_i = 0 + while True: + try: + frame = await asyncio.to_thread(fetch_live) + source, wait = ("opensky live" if WORLD else "adsb.fi live"), POLL_SECONDS + except Exception: + frame = _replay_frame(replay_i) + replay_i += 1 + source, wait = "bundled replay", REPLAY_SECONDS + _advance(frame) + async with self: + if not self.polling: + break + self.source = source + self.n_aircraft = len(frame["hex"]) + self.frame_rev += 1 + await asyncio.sleep(wait) + + +# --- layout ----------------------------------------------------------------- + + +def sky_view() -> rx.Component: + return rx.vstack( + reflex_xy.chart( + Flights.sky, + on_point_click=Flights.on_click, + on_select_end=Flights.on_select, + height="520px", + id="sky", + ), + rx.hstack( + rx.button( + rx.cond(Flights.polling, "stop", "start tracking"), + on_click=Flights.poll, + id="poll-btn", + ), + rx.button("unfollow", on_click=Flights.unfollow, variant="soft"), + kv( + "followed", + rx.cond( + Flights.followed_hex != "", + f"{Flights.followed['callsign']} · {Flights.followed['type']} · " + f"{Flights.followed['alt']} ft · {Flights.followed['gs']} kt", + "click an aircraft", + ), + ), + spacing="3", + align="center", + ), + width="100%", + spacing="3", + ) + + +def panels_view() -> rx.Component: + return rx.vstack( + rx.grid( + reflex_xy.chart(Flights.follow, height="260px", id="follow"), + reflex_xy.chart(Flights.altitudes, height="260px", id="altitudes"), + columns="2", + gap="1rem", + width="100%", + ), + rx.text(Flights.sel_note, size="2", color_scheme="gray"), + width="100%", + spacing="2", + ) + + +def page() -> rx.Component: + return rx.container( + rx.vstack( + rx.heading(f"live flights {PLACE}", size="8"), + nav("flights"), + rx.text( + ( + "Every airborne aircraft OpenSky can see — the whole planet, " + "republished through a figure var every cycle. Color is " + "altitude, size is ground speed. Falls back to dead-reckoning " + "a bundled real capture when the API is unreachable." + if WORLD + else "Real ADS-B positions from the adsb.fi open-data network, " + "republished through a figure var every few seconds. Color is " + "altitude, size is ground speed. Falls back to a bundled real " + "capture when the API is unreachable." + ), + color_scheme="gray", + size="3", + ), + section( + "1 · The sky", + ( + "~14,000 aircraft worldwide with trails, over the full Natural " + "Earth 50m coastline and borders (~80k points of xy.segments) — " + "every poll cycle rebuilds and re-ships the whole figure as " + "binary columns. Pan/zoom stays smooth throughout; the viewport " + "survives each republish. Click a plane to follow it; " + "shift-drag to box-select." + if WORLD + else "~900 aircraft in a 250 nm circle, over a Natural Earth " + "coastline drawn with xy.segments. Pan/zoom freely — the " + "viewport survives each republish. Click a plane to follow it; " + "shift-drag to box-select." + ), + sky_view(), + code_accordion(Flights.sky, Flights.poll, Flights.on_click, sky_view), + ), + section( + "2 · Follow + cross-filter", + "Left: the followed aircraft's altitude trail, rebuilt from its " + "recent frames. Right: altitude histogram of the current frame, " + "cross-filtered by the map's box-selection.", + panels_view(), + code_accordion(Flights.follow, Flights.altitudes, Flights.on_select), + ), + spacing="5", + width="100%", + ), + size="4", + padding_y="28px", + ) diff --git a/examples/reflex/xy_reflex_demo/ui.py b/examples/reflex/xy_reflex_demo/ui.py new file mode 100644 index 00000000..783b8fde --- /dev/null +++ b/examples/reflex/xy_reflex_demo/ui.py @@ -0,0 +1,92 @@ +"""Shared page furniture for the showcase pages: the section card, the +key/value readout row, and the "Code" accordion that shows a section's own +source via ``inspect.getsource``.""" + +from __future__ import annotations + +import inspect +from typing import Any + +import reflex as rx +from reflex_xy.tokens import BUILDER_ATTR + + +def _source(obj: Any) -> str: + """Source of a plain function, an ``@reflex_xy.figure`` var, or an + ``@rx.event`` handler.""" + fget = getattr(obj, "_fget", None) + if fget is not None: # a @reflex_xy.figure / computed var + builder = getattr(fget, BUILDER_ATTR, None) + return inspect.getsource(builder if builder is not None else fget) + handler = getattr(obj, "fn", None) + if handler is not None: # an @rx.event handler + return inspect.getsource(handler) + return inspect.getsource(obj) + + +def code_accordion(*objs: Any) -> rx.Component: + source = "\n\n".join(inspect.cleandoc("\n" + _source(obj)) for obj in objs) + return rx.el.details( + rx.el.summary( + "Code", + cursor="pointer", + padding="0.75rem 1rem", + font_weight="700", + font_size="0.85rem", + list_style="none", + ), + rx.el.pre( + rx.el.code(source), + margin="0", + padding="1rem 1.15rem", + background="#0b1120", + color="#e5e7eb", + font_size="0.78rem", + line_height="1.55", + overflow_x="auto", + white_space="pre", + border_top="1px solid rgba(148,163,184,0.2)", + ), + border_top="1px solid var(--gray-5)", + width="100%", + ) + + +def section(title: str, blurb: str, body: rx.Component, code: rx.Component) -> rx.Component: + return rx.box( + rx.box( + rx.heading(title, size="5"), + rx.text(blurb, color_scheme="gray", size="2", margin_top="0.25rem"), + padding="1rem 1.15rem", + ), + rx.box(body, padding="0 1.15rem 1.15rem"), + code, + border="1px solid var(--gray-5)", + border_radius="12px", + background="var(--gray-1)", + overflow="hidden", + width="100%", + ) + + +def kv(label: str, value: Any) -> rx.Component: + return rx.hstack( + rx.badge(label), + rx.text(value, font_family="monospace", font_size="13px"), + spacing="3", + align="center", + ) + + +def nav(current: str) -> rx.Component: + """Small page switcher shown under each page heading.""" + links = [("concepts", "/"), ("flights", "/flights")] + return rx.hstack( + *[ + rx.badge(name, variant="solid") + if name == current + else rx.link(rx.badge(name, variant="soft"), href=href) + for name, href in links + ], + spacing="2", + ) diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 91124a26..fa684dc4 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,7 +1,9 @@ """reflex-xy showcase: ways to link chart data into a Reflex app. -One page of five sections; each has a "Code" accordion showing its own source -via `inspect.getsource`. +Two pages: this module is the concepts page (five sections, each with a +"Code" accordion showing its own source via `inspect.getsource`), and +`flights.py` (route ``/flights``) applies the same patterns to real ADS-B +air-traffic data. 1. **Live figure var + events.** A 1M-point drillable scatter from an ``@reflex_xy.figure`` state method; its data rides the app websocket while @@ -26,17 +28,17 @@ from __future__ import annotations import asyncio -import inspect from functools import lru_cache -from typing import Any import numpy as np import reflex as rx import reflex_xy -from reflex_xy.tokens import BUILDER_ATTR import xy +from . import flights +from .ui import code_accordion, kv, nav, section + POINTS = 1_000_000 RNG_SEED = 11 @@ -294,77 +296,7 @@ async def stream(self): await asyncio.sleep(0.25) -# --- introspection: the "Code" accordions ----------------------------------- - - -def _source(obj: Any) -> str: - """Source of a plain function, an ``@reflex_xy.figure`` var, or an - ``@rx.event`` handler.""" - fget = getattr(obj, "_fget", None) - if fget is not None: # a @reflex_xy.figure / computed var - builder = getattr(fget, BUILDER_ATTR, None) - return inspect.getsource(builder if builder is not None else fget) - handler = getattr(obj, "fn", None) - if handler is not None: # an @rx.event handler - return inspect.getsource(handler) - return inspect.getsource(obj) - - -def code_accordion(*objs: Any) -> rx.Component: - source = "\n\n".join(inspect.cleandoc("\n" + _source(obj)) for obj in objs) - return rx.el.details( - rx.el.summary( - "Code", - cursor="pointer", - padding="0.75rem 1rem", - font_weight="700", - font_size="0.85rem", - list_style="none", - ), - rx.el.pre( - rx.el.code(source), - margin="0", - padding="1rem 1.15rem", - background="#0b1120", - color="#e5e7eb", - font_size="0.78rem", - line_height="1.55", - overflow_x="auto", - white_space="pre", - border_top="1px solid rgba(148,163,184,0.2)", - ), - border_top="1px solid var(--gray-5)", - width="100%", - ) - - -# --- layout ----------------------------------------------------------------- - - -def section(title: str, blurb: str, body: rx.Component, code: rx.Component) -> rx.Component: - return rx.box( - rx.box( - rx.heading(title, size="5"), - rx.text(blurb, color_scheme="gray", size="2", margin_top="0.25rem"), - padding="1rem 1.15rem", - ), - rx.box(body, padding="0 1.15rem 1.15rem"), - code, - border="1px solid var(--gray-5)", - border_radius="12px", - background="var(--gray-1)", - overflow="hidden", - width="100%", - ) - - -def kv(label: str, value: Any) -> rx.Component: - return rx.hstack( - rx.badge(label), - rx.text(value, font_family="monospace", font_size="13px"), - spacing="3", - align="center", - ) +# --- layout (shared furniture lives in ui.py) -------------------------------- # §1 wiring — the live figure var and its semantic events @@ -439,6 +371,7 @@ def index() -> rx.Component: return rx.container( rx.vstack( rx.heading("xy × reflex", size="8"), + nav("concepts"), rx.text( "Chart data rides the app websocket as binary buffers, with " "kernel-side drilldown. Each section shows its own source below.", @@ -534,3 +467,4 @@ def index() -> rx.Component: app = rx.App() app.add_page(index, title="reflex-xy showcase") +app.add_page(flights.page, route="/flights", title="reflex-xy — live flights") diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 21d89f30..43731a39 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1468,13 +1468,13 @@ export class ChartView { // FBO realloc is deferred to the next actual pick (_renderPick checks dims). // The view request re-decimates/re-bins at the new pixel size (§28), so a // bigger chart gains real detail, not just stretched pixels. - _resize(cssW, cssH) { + _resize(cssW, cssH, force = false) { 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; // Browser zoom changes devicePixelRatio with no container resize (R7); // re-read it so backing stores stay crisp on a pure-DPR change too. const dpr = window.devicePixelRatio || 1; - if (w === this.size.w && h === this.size.h && dpr === this.dpr) return; + if (!force && w === this.size.w && h === this.size.h && dpr === this.dpr) return; this.dpr = dpr; this.size.w = w; this.size.h = h; @@ -1513,6 +1513,36 @@ export class ChartView { this._scheduleViewRequest(); } + _makeTitleEl(text) { + const t = document.createElement("div"); + t.textContent = text; + t.style.cssText = "position:absolute;top:6px;left:0;right:0;"; + this._applySlot(t, "title"); + return t; + } + + // A payload swap replaces this.spec wholesale, but the title is plain DOM + // chrome built at mount — re-sync it (and the region's aria-label) or a + // republished figure keeps showing its mount-time title. A presence change + // moves the plot's top margin (_layout), so it forces the resize path. + _syncTitle() { + const title = this.spec.title; + this.root.setAttribute("aria-label", title ? `Chart: ${title}` : "Interactive chart"); + if (title && this._titleEl) { + this._titleEl.textContent = title; + return; + } + if (!title && !this._titleEl) return; + if (title) { + this._titleEl = this._makeTitleEl(title); + this.root.insertBefore(this._titleEl, this.chrome); + } else { + this._titleEl.remove(); + this._titleEl = null; + } + this._resize(this.size.w, this.size.h, true); + } + _buildDom(el) { const s = this.spec; const root = document.createElement("div"); @@ -1559,12 +1589,10 @@ export class ChartView { this.a11yLive.style.cssText = XY_SR_ONLY_STYLE; root.appendChild(this.a11yLive); + this._titleEl = null; 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._titleEl = this._makeTitleEl(s.title); + root.appendChild(this._titleEl); } this.chrome = document.createElement("canvas"); @@ -3353,6 +3381,10 @@ export class ChartView { if (!hit || !hit.g) return; const g = hit.g; if (g.trace.kind !== "scatter" || g.tier === "density") return; + // Structural guard: a destroyed group has null buffers (§27 — GPU state + // is a rebuildable cache); drawing from one is a hard error, so treat a + // stale hit as "nothing to highlight". + if (!g.xBuf || !g.yBuf) 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); @@ -5072,6 +5104,14 @@ export class ChartView { _destroyTraceResources(g, texSeen) { if (!g) return; + // The hover hit caches a direct group reference; once this group's + // buffers are freed it must not survive into the next draw (a republish + // rebuilds the groups, so the pick is re-resolved on the next move). + if (this._hoverTarget && this._hoverTarget.g === g) { + this._hoverTarget = null; + this._hoverId = -1; + this._hideTooltip(); + } this._destroyDensitySample(g); this._deleteVaos(g); this._deleteVaos(g.drill); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 8de436be..a00c6cf7 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -245,6 +245,7 @@ Object.assign(ChartView.prototype, { this.spec = spec; this.axes = this._normalizeAxes(spec); this._payload = payload; + this._syncTitle(); this.view0 = this._copyView({ ranges: Object.fromEntries(Object.entries(this.axes).map(([id, axis]: any) => [id, [...axis.range]])), }); diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index e508764d..03e56697 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -450,6 +450,7 @@ Object.assign(ChartView.prototype, { this.markStyle = spec.mark_style || {}; this.axes = this._normalizeAxes(spec); this._payload = buffer; + this._syncTitle(); // A full payload re-homes the view to its own axis ranges (reflex-integration // §4 "state-driven rebuild"): unlike streaming append, it carries no // follow-policy, so it behaves like a fresh mount of the new data. Clear the diff --git a/python/reflex-xy/reflex_xy/namespace.py b/python/reflex-xy/reflex_xy/namespace.py index 5fa7136c..fa021744 100644 --- a/python/reflex-xy/reflex_xy/namespace.py +++ b/python/reflex-xy/reflex_xy/namespace.py @@ -58,6 +58,26 @@ _MAX_PX_HINT = 8192 _MIN_PX_HINT = 16 +# socket.io-parser's Decoder ships with `maxAttachments: 10`; a binary packet +# with more attachments makes the BROWSER close the whole shared websocket as +# a parse error — which the app plane then re-opens, re-subscribing every +# chart into an infinite reconnect loop. Figures stay under the limit or fall +# back to the joined single-blob payload (`buffer_layout` != "split", which +# the client's `toSpans` already handles). +_MAX_WIRE_ATTACHMENTS = 10 + + +def _build_wire_payload( + figure: "Figure", px: Optional[int] = None +) -> tuple[dict[str, Any], list[Any]]: + """Split-buffer payload, unless it would exceed the attachment limit.""" + spec, raw = figure.build_payload_split(px) + if len(raw) <= _MAX_WIRE_ATTACHMENTS: + return spec, list(raw) + spec, blob = figure.build_payload(px) + return spec, [blob] + + # 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"]]] @@ -132,7 +152,7 @@ async def on_sub(self, sid: str, data: Any) -> None: 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) + spec, raw = await asyncio.to_thread(_build_wire_payload, entry.figure, px) await self.emit( "payload", { @@ -170,10 +190,19 @@ async def on_msg(self, sid: str, data: Any) -> None: if reply is None: return message, buffers = reply + wire_buffers = _buffer_bytes(buffers) + if len(wire_buffers) > _MAX_WIRE_ATTACHMENTS: + # Never exceed the browser parser's attachment limit: one oversized + # packet closes the shared websocket for the whole app. Channel + # replies are bounded by construction, so this is a contract check. + await self.emit( + "err", {"fig": token, "error": "reply exceeds wire attachment limit"}, to=sid + ) + return envelope: dict[str, Any] = { "fig": token, "message": _plain(message), - "buffers": _buffer_bytes(buffers), + "buffers": wire_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. @@ -201,7 +230,7 @@ async def broadcast_message( 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) + spec, raw = await asyncio.to_thread(_build_wire_payload, entry.figure) await self.emit( "payload", { diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index e25a8a29..2feb54e2 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -115,6 +115,22 @@ 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). +**Attachment cap (hard browser limit).** socket.io-parser's `Decoder` ships +with `maxAttachments: 10`; one binary packet with more attachments makes the +browser throw `"too many attachments"`, which `Manager.ondata` converts into +closing the *entire shared websocket* as a parse error — the app plane then +reconnects, every chart re-`sub`s, the oversized payload is re-sent, and the +connection loops forever with no console error. The namespace therefore never +emits more than 10 attachments per packet (`_MAX_WIRE_ATTACHMENTS`): +payloads whose split layout would exceed the cap fall back to the joined +single-blob `build_payload()` form (no `buffer_layout: "split"` in the spec; +the wrapper's `toSpans` already dispatches on that flag), trading the join +copy for staying inside the parser's budget. `msg` replies are bounded by +channel construction; the namespace enforces the same cap as a contract +check and answers `err` instead of emitting an unparseable packet. +Regression: `tests/reflex_adapter/test_socket_data_plane.py::` +`test_sub_over_attachment_limit_ships_single_blob`. + The envelope is below; the `m` payload it carries is specified field by field in `spec/design/wire-protocol.md`. @@ -508,6 +524,14 @@ the replacement. The client retains brush geometry, so points arriving in a re-drill can reconstruct their selection mask without a second selection request. +Viewport and selection are the *only* interaction state that survives a +republish. A hover highlight is dropped the moment its trace's GPU resources +are destroyed (the cached pick references freed buffers; the next pointer +move re-resolves it against the new traces), and mount-time DOM chrome that +mirrors the spec — the title and the container's `aria-label` — is re-synced +on every in-place swap, so a figure whose title carries live state (the +flights page's aircraft counter) stays current without a teardown. + One handler can route several charts by stable token: ```python @@ -571,11 +595,23 @@ python/reflex-xy/ reflex_xy/payload_asset.py static tier: Chart -> content-addressed XYBF asset in assets/xy/ (§3.4) reflex_xy/assets/ XYChart.jsx; links xy's installed render client -examples/reflex/ (repo root) reflex-xy showcase: figure-var drilldown with - hover/click/select events, a slider-driven + - cross-filtered histogram, a streaming line, an - on_view_change-computed detail chart, and both - fixed-data tiers (direct Chart + inline() token) +examples/reflex/ (repo root) reflex-xy showcase, two pages. "/": figure-var + drilldown with hover/click/select events, a + slider-driven + cross-filtered histogram, a + streaming line, an on_view_change-computed detail + chart, and both fixed-data tiers (direct Chart + + inline() token). "/flights": the same patterns as + a throughput showcase on live ADS-B data — world + mode (default) polls OpenSky /states/all (~14k + aircraft globally, full-figure republish per + cycle as one multi-MB blob, Natural Earth 50m + world basemap via segments marks, trails); region + mode polls an adsb.fi 250nm circle at up-to-1s + cadence. Click-to-follow, box-select cross- + filter; offline fallback = bundled real captures + (region: 10 frames; world: one 14k-aircraft + capture dead-reckoned along track at ground + speed) examples/fastapi/ (repo root) the same charts + a live 100M drilldown served from a plain FastAPI app (no committed HTML) tests/reflex_adapter/ 69 tests: token/registry/var/bridge/payload-asset diff --git a/tests/reflex_adapter/test_socket_data_plane.py b/tests/reflex_adapter/test_socket_data_plane.py index 8a00d058..fe2dbf1e 100644 --- a/tests/reflex_adapter/test_socket_data_plane.py +++ b/tests/reflex_adapter/test_socket_data_plane.py @@ -132,6 +132,36 @@ async def main(): run(main()) +def test_sub_over_attachment_limit_ships_single_blob(_fresh_registry): + """socket.io-parser's browser Decoder defaults to `maxAttachments: 10` and + closes the WHOLE shared websocket ("too many attachments" -> "parse + error") on any binary packet exceeding it — which then reconnect-loops + the app. Buffer-heavy figures must fall back to the joined single-blob + payload, which the wrapper's `toSpans` handles via `buffer_layout`.""" + + async def main(): + xs = np.linspace(0.0, 1.0, 64) + figure = xy.scatter_chart( + *[xy.scatter(xs, xs * k, color=xs, size=xs) for k in (1.0, 2.0, 3.0)], + width=640, + height=400, + ).figure() + _, raw = figure.build_payload_split(640) + assert len(raw) > 10, "premise: this figure must exceed the parser cap" + token = registry.register(figure) + async with data_plane_server() as (url, _): + client = await connect_client(url) + collector = Collector(client) + await client.emit("sub", {"fig": token, "px": 640}, namespace="/_xy") + payload = await collector.next(collector.payloads) + await client.disconnect() + assert payload["fig"] == token + assert payload["spec"].get("buffer_layout") != "split" + assert len(payload["buffers"]) == 1 + + run(main()) + + def test_msg_round_trip_pick_and_select(_fresh_registry): async def main(): token = registry.register(make_figure(16)) diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index d987d665..fc38a774 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 +import json from pathlib import Path import pytest @@ -280,3 +282,79 @@ def test_categorical_tick_bounds_follow_anchor_rotation_and_extra_axis_side( assert extra, result assert all(row["side"] == "" for row in extra), result assert all(row["pinnedRight"] for row in extra), result + + +def test_republish_clears_stale_hover_and_syncs_title(tmp_path: Path) -> None: + """An in-place payload swap must not leave mount-time interaction chrome + pointing at the outgoing scene. + + Reported from the reflex flights page (a figure var republished every poll + cycle): hovering an aircraft caches a direct trace-group reference, and the + swap frees that group's GPU buffers — the very next draw crashed on the + nulled ``xBuf`` (``Cannot read properties of null (reading '_fcId')``). + The title is the same class of bug in the other direction: it is plain DOM + built at mount, so a republished title (here carrying a live counter) + silently kept its mount-time text. + """ + before = xy.scatter_chart( + xy.scatter([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]), + title="3 aircraft · idle", + width=320, + height=240, + ) + after = xy.scatter_chart( + xy.scatter([0.0, 1.0, 2.0, 3.0], [0.5, 0.0, 1.0, 0.25]), + title="4 aircraft · live", + width=320, + height=240, + ) + next_spec, next_buf = after.figure().build_payload() + script = ( + _PRELUDE + + f"const NEXT_SPEC = {json.dumps(next_spec)};\n" + + f'const NEXT_B64 = "{base64.b64encode(next_buf).decode("ascii")}";\n' + + """ + const b64ToBytes = (b64) => { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + }; + const titleBefore = view._titleEl ? view._titleEl.textContent : null; + const g = view.gpuTraces.find((t) => t.trace.kind === "scatter"); + // What a real pointer pick caches (ChartView._hover): a direct group ref. + view._hoverTarget = { g, index: 0, trace: 0 }; + // Non-animated in-place swap: destroys the outgoing groups synchronously. + const applied = view.updatePayload(NEXT_SPEC, b64ToBytes(NEXT_B64)); + const hoverCleared = view._hoverTarget === null; + const oldBuffersFreed = g.xBuf === null; + let drawError = null; + try { + view._drawNow(); + view._raf = null; + } catch (error) { + drawError = String(error); + } + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + applied, + titleBefore, + titleAfter: view._titleEl ? view._titleEl.textContent : null, + ariaAfter: view.root.getAttribute("aria-label"), + hoverCleared, + oldBuffersFreed, + drawError, + })); +""" + + _POSTLUDE + ) + result = _probe(before, script, tmp_path, "republish hover and title") + + assert result["applied"] is True, result + assert result["titleBefore"] == "3 aircraft · idle", result + # The destroyed group's buffers really were freed, so a surviving hover + # reference would have been the crash. + assert result["oldBuffersFreed"] is True, result + assert result["hoverCleared"] is True, result + assert result["drawError"] is None, result + assert result["titleAfter"] == "4 aircraft · live", result + assert result["ariaAfter"] == "Chart: 4 aircraft · live", result