diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d8fa687..4f75630f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,45 @@ in the README).
`memory_report`), and `Chart.figure()` remains as an advanced escape hatch
to the internal engine object.
+### Changed
+- **Density surfaces now wear the data's own colors (LOD doc §2).** A Tier-2
+ scatter's aggregated view colors each cell with the alpha-weighted **mean of
+ its binned points' resolved colors** (continuous colormap, categorical
+ palette, and direct-RGBA channels alike; averaged in linear light through a
+ deterministic integer pipeline), while the binned **count now drives only
+ the alpha channel** — more points, deeper color; fewer points, lighter.
+ Previously the count itself was colormapped, so a colored scatter's density
+ view matched neither its points nor its legend and every density⇄points
+ zoom transition recolored the chart. The wire ships a per-cell RGBA plane
+ (`density.rgba`, recorded as `color_agg: "mean"`); constant-color traces
+ keep the compact count-only grid and a client-side tint. The count pyramid
+ gained matching mean-color planes (`xy_pyramid_build_color` /
+ `xy_pyramid_compose_color`; colored pyramids refuse in-place appends and
+ rebuild lazily, and their base scan fans out ≤4 workers so a 100M-point
+ build lands in about a quarter of the time), the SVG/PNG exporters and the
+ standalone `to_html` re-bin worker follow the same law, and the drill
+ handoff is now intensity-only — drilled points arrive in their native
+ colors (`density_colormap` left the points wire), and the aggregate
+ backdrop retires once a drill settles inside its window (T10), returning
+ the moment the view leaves it or a refinement goes pending. The color
+ channel is no longer listed in `dropped_channels` at Tier 2, and a
+ continuous-channel density scatter renders its colorbar again. C ABI v40
+ (`xy_bin_2d_mean_color` + pyramid color entry points).
+- **The FastAPI live-drilldown example is a thin transport over the engine.**
+ Every window is served through `Figure.density_view` (mean-color pyramid
+ warmed at startup); the demo's pre-pyramid density machinery — an
+ integral-image overview server-side and ~350 lines of page JS (local
+ re-bins, request parking, per-client staleness maps), all count-only — is
+ gone, and the page JS is a POST transport plus a status badge. Round-trip
+ replies ship as `XYBF` binary frames (wire-protocol §7) instead of
+ base64-in-JSON.
+- **Zooms inside an exact drill window skip the kernel round-trip (T12).**
+ Once a points reply has shipped its window exactly (`reduction: "none"`),
+ the client answers any contained view from the marks it already holds and
+ sends no `density_view` request, until the view leaves the window, the
+ drill dies, or the zoom is deep enough (1/256 of the window span) to need
+ a §16 re-centered f32 encoding.
+
### Added
- **Export format parity and a unified export API (ENG-10447).**
`to_image(format=...)` and extension-inferred, atomic `write_image(path)`
diff --git a/examples/fastapi/README.md b/examples/fastapi/README.md
index 5dcbe7e0..e9023568 100644
--- a/examples/fastapi/README.md
+++ b/examples/fastapi/README.md
@@ -14,7 +14,11 @@ Two integration surfaces:
- **Server drilldown tier** — `GET /drilldown` serves a 100M-point scatter
whose density surface refines into exact points on zoom, using
`POST /api/xy/drilldown` (a Starlette endpoint in
- [`live_drilldown.py`](live_drilldown.py)) for the view round-trips.
+ [`live_drilldown.py`](live_drilldown.py)) for the view round-trips. Each
+ reply is an `XYBF` binary frame — compact JSON metadata plus raw f32/u8
+ buffers — decoded in the browser with the bundled `xy.decodeFrame`, so the
+ density grids and point buffers that dominate a drill never pay a base64
+ encode/decode or its ~33% inflation.
## Run
diff --git a/examples/fastapi/live_drilldown.py b/examples/fastapi/live_drilldown.py
index 3e9922c9..79860bbd 100644
--- a/examples/fastapi/live_drilldown.py
+++ b/examples/fastapi/live_drilldown.py
@@ -1,3 +1,26 @@
+"""FastAPI live drilldown: a 100M-point scatter served by the engine's own LOD.
+
+The pattern this example demonstrates is deliberately small:
+
+- build one figure (`live_figure`), serve its payload plus the bundled render
+ client as a single HTML page;
+- wire `ChartView` to a custom transport — a `comm` object whose `send` POSTs
+ the client's messages to one endpoint and feeds each reply — an `XYBF`
+ binary frame (`spec/design/wire-protocol.md` §7): small JSON metadata plus
+ raw f32/u8 buffers, decoded in the browser with `xy.decodeFrame` — back
+ through `onMessage`, with no base64 on either side;
+- the endpoint dispatches `density_view` / `pick` straight to the figure,
+ under one lock.
+
+The LOD ladder itself lives in the engine: `Figure.density_view` picks the
+tier per window — mean-color pyramid composition for wide views, exact
+re-bins near the drill budget, exact points inside it (LOD doc §2/§4) — and
+the render client debounces its requests, drops stale replies by `seq`, and
+keeps the best cached density texture drawn until a fresh one lands (§17
+stale-while-revalidate), so the host app carries no aggregation or request
+bookkeeping of its own.
+"""
+
from __future__ import annotations
import base64
@@ -5,22 +28,24 @@
import os
import threading
import warnings
-from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Union
import numpy as np
from starlette.requests import Request
-from starlette.responses import JSONResponse
+from starlette.responses import JSONResponse, Response
import xy
-# The live drilldown server probes the engine directly (traces, density_view,
-# drill bookkeeping), so it works on the internal figure compiled from the
-# public composition API via `Chart.figure()`.
+# The live drilldown server probes the engine directly (density_view, pick),
+# so it works on the internal figure compiled from the public composition API
+# via `Chart.figure()`.
from xy._figure import Figure
-from xy.config import DRILL_EXIT_FACTOR, SCATTER_DENSITY_THRESHOLD
-from xy.lod import grid_shape
+
+# `encode_frame` builds the XYBF binary transport frame (wire-protocol.md §7)
+# the browser decodes with the bundled `xy.decodeFrame`; it is re-exported from
+# the transport-neutral channel module, the same seam the Reflex adapter uses.
+from xy.channel import encode_frame
from xy.widget import bundled_js
_DEFAULT_LIVE_POINTS = 100_000_000
@@ -52,12 +77,9 @@ def _live_points() -> int:
# Point count for the drilldown demo; override with XY_LIVE_POINTS.
LIVE_SCATTER_POINTS = _live_points()
LIVE_DRILLDOWN_ROUTE = "/api/xy/drilldown"
-DENSITY_OVERVIEW_BINS = 6144
-DENSITY_OVERVIEW_CHUNK = 1_000_000
-OVERVIEW_EXACT_FACTOR = 4.0
+# One figure serves every request; density_view mutates its drill bookkeeping,
+# so requests (and payload builds) serialize.
_FIGURE_LOCK = threading.Lock()
-_DENSITY_SEQ_LOCK = threading.Lock()
-_LATEST_DENSITY_SEQ: dict[str, int] = {}
def _point_label(n: int) -> str:
@@ -123,236 +145,55 @@ def colored_scatter_figure(
@lru_cache(maxsize=1)
def live_figure() -> Figure:
- return live_store().figure
-
-
-@dataclass(frozen=True)
-class DensityOverview:
- integral: np.ndarray
- x_range: tuple[float, float]
- y_range: tuple[float, float]
- width: int
- height: int
-
- @classmethod
- def build(cls, fig: Figure, trace_id: int = 0) -> "DensityOverview":
- t = fig.traces[trace_id]
- x0, x1 = fig.x_range()
- y0, y1 = fig.y_range()
- w = h = DENSITY_OVERVIEW_BINS
- grid = np.zeros((h, w), dtype=np.uint32)
- flat_grid = grid.reshape(-1)
- x_scale = w / (x1 - x0)
- y_scale = h / (y1 - y0)
- xv = t.x.values
- yv = t.y.values
-
- for start in range(0, t.n_points, DENSITY_OVERVIEW_CHUNK):
- end = min(start + DENSITY_OVERVIEW_CHUNK, t.n_points)
- xs = xv[start:end]
- ys = yv[start:end]
- valid = np.isfinite(xs) & np.isfinite(ys)
- valid &= (xs >= x0) & (xs < x1) & (ys >= y0) & (ys < y1)
- if not np.any(valid):
- continue
- ix = ((xs[valid] - x0) * x_scale).astype(np.int64)
- iy = ((ys[valid] - y0) * y_scale).astype(np.int64)
- np.clip(ix, 0, w - 1, out=ix)
- np.clip(iy, 0, h - 1, out=iy)
- counts = np.bincount(iy * w + ix)
- flat_grid[: len(counts)] += counts.astype(np.uint32, copy=False)
-
- summed = np.cumsum(grid, axis=0, dtype=np.uint32)
- summed = np.cumsum(summed, axis=1, dtype=np.uint32)
- integral = np.zeros((h + 1, w + 1), dtype=np.uint32)
- integral[1:, 1:] = summed
- return cls(integral=integral, x_range=(x0, x1), y_range=(y0, y1), width=w, height=h)
-
- def _edges(
- self, lo: float, hi: float, domain: tuple[float, float], cells: int, bins: int
- ) -> np.ndarray:
- d0, d1 = domain
- span = d1 - d0
- edges = (np.linspace(lo, hi, cells + 1) - d0) * (bins / span)
- return np.clip(np.floor(edges).astype(np.int64), 0, bins)
-
- def _view_bounds(self, x0: float, x1: float, y0: float, y1: float) -> tuple[int, int, int, int]:
- bx0, bx1 = self._edges(x0, x1, self.x_range, 1, self.width)
- by0, by1 = self._edges(y0, y1, self.y_range, 1, self.height)
- return int(bx0), int(bx1), int(by0), int(by1)
-
- def count(self, x0: float, x1: float, y0: float, y1: float) -> int:
- bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1)
- if bx1 <= bx0 or by1 <= by0:
- return 0
- ii = self.integral
- return int(ii[by1, bx1]) - int(ii[by0, bx1]) - int(ii[by1, bx0]) + int(ii[by0, bx0])
-
- def density(
- self,
- x0: float,
- x1: float,
- y0: float,
- y1: float,
- w: int,
- h: int,
- visible: int,
- ) -> np.ndarray | None:
- bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1)
- source_w = bx1 - bx0
- source_h = by1 - by0
- if source_w < 16 or source_h < 16:
- return None
- w, h = grid_shape(w, h, visible)
- w = max(16, min(w, source_w))
- h = max(16, min(h, source_h))
- x_edges = self._edges(x0, x1, self.x_range, w, self.width)
- y_edges = self._edges(y0, y1, self.y_range, h, self.height)
- ii = self.integral
- x_lo = x_edges[:-1]
- x_hi = x_edges[1:]
- y_lo = y_edges[:-1]
- y_hi = y_edges[1:]
- grid = (
- ii[y_hi[:, None], x_hi[None, :]].astype(np.int64)
- - ii[y_lo[:, None], x_hi[None, :]].astype(np.int64)
- - ii[y_hi[:, None], x_lo[None, :]].astype(np.int64)
- + ii[y_lo[:, None], x_lo[None, :]].astype(np.int64)
- )
- return grid.astype(np.float32, copy=False)
-
-
-@dataclass(frozen=True)
-class LiveStore:
- figure: Figure
- overview: DensityOverview
-
-
-@lru_cache(maxsize=1)
-def live_store() -> LiveStore:
fig = colored_scatter_figure()
- return LiveStore(figure=fig, overview=DensityOverview.build(fig))
+ # Warm the kernel's mean-color pyramid (LOD doc §4) at startup so the
+ # first interactive zoom skips the one-time build; the reply itself is
+ # discarded.
+ x0, x1 = fig.x_range()
+ y0, y1 = fig.y_range()
+ fig.density_view(0, x0, x1, y0, y1, 512, 384)
+ return fig
def _b64(buf: bytes) -> str:
return base64.b64encode(buf).decode("ascii")
-def _seq_value(raw: Any) -> int | None:
- try:
- return int(raw)
- except (TypeError, ValueError):
- return None
-
-
-def _density_client_id(content: dict[str, Any]) -> str:
- return str(content.get("client_id") or "default")
-
-
-def _mark_latest_density(content: dict[str, Any]) -> tuple[str, int | None]:
- client_id = _density_client_id(content)
- seq = _seq_value(content.get("seq"))
- if seq is None:
- return client_id, None
- with _DENSITY_SEQ_LOCK:
- latest = _LATEST_DENSITY_SEQ.get(client_id, -1)
- if seq > latest:
- _LATEST_DENSITY_SEQ[client_id] = seq
- return client_id, seq
-
-
-def _density_is_stale(client_id: str, seq: int | None) -> bool:
- if seq is None:
- return False
- with _DENSITY_SEQ_LOCK:
- return seq < _LATEST_DENSITY_SEQ.get(client_id, -1)
+# Round-trip replies travel as XYBF binary frames (wire-protocol.md §7): the
+# reply message is the frame's compact JSON metadata and each numeric buffer
+# rides raw and 8-byte aligned, so the browser decodes one `xy.decodeFrame`
+# and hands the kernel zero-copy views. First paint still embeds its blob as
+# base64 in the page below, because inline HTML has no binary channel
+# (wire-protocol.md §6).
+_FRAME_MEDIA_TYPE = "application/octet-stream"
-def _response(message: dict[str, Any], buffers: list[bytes] | None = None) -> JSONResponse:
- return JSONResponse(
- {
- "message": message,
- "buffers": [_b64(buffer) for buffer in (buffers or [])],
- }
- )
+def _frame_response(message: dict[str, Any], buffers: list[bytes] | None = None) -> Response:
+ return Response(encode_frame(message, buffers or []), media_type=_FRAME_MEDIA_TYPE)
-def _live_density_view(
- store: LiveStore, trace_id: int, x0: float, x1: float, y0: float, y1: float, w: int, h: int
-) -> tuple[dict[str, Any], list[bytes]]:
- fig = store.figure
- if trace_id != 0:
- return fig.density_view(trace_id, x0, x1, y0, y1, w, h)
- t = fig.traces[trace_id]
- if not t.use_density():
- return {"traces": []}, []
-
- lo_x, hi_x = min(x0, x1), max(x0, x1)
- lo_y, hi_y = min(y0, y1), max(y0, y1)
- budget = SCATTER_DENSITY_THRESHOLD * (DRILL_EXIT_FACTOR if t.drill_mode else 1.0)
- visible = store.overview.count(lo_x, hi_x, lo_y, hi_y)
- if visible <= budget * OVERVIEW_EXACT_FACTOR:
- return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h)
-
- grid = store.overview.density(lo_x, hi_x, lo_y, hi_y, w, h, visible)
- if grid is None:
- return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h)
-
- if t.drill_mode:
- t.drill_seq += 1
- t.drill_mode = False
- t.shipped_sel = None
- return (
- {
- "traces": [
- {
- "id": trace_id,
- "mode": "density",
- "visible": visible,
- "density": {
- "buf": 0,
- "w": int(grid.shape[1]),
- "h": int(grid.shape[0]),
- "max": float(grid.max()) if grid.size else 0.0,
- "x_range": [lo_x, hi_x],
- "y_range": [lo_y, hi_y],
- },
- }
- ]
- },
- [grid.reshape(-1).astype(np.float32, copy=False).tobytes()],
- )
+async def drilldown_endpoint(request: Request) -> Response:
+ """Answer the render client's messages with the engine's own replies.
-
-async def drilldown_endpoint(request: Request) -> JSONResponse:
+ `Figure.density_view` owns the whole ladder (mean-color pyramid for wide
+ windows, exact re-bins near the budget, drill-in to real points,
+ hysteresis, recorded reductions), and the render client discards stale
+ replies by `seq`, so this endpoint is one lock and one dispatch. Each
+ reply ships as an XYBF binary frame (`_frame_response`); malformed
+ requests return a plain JSON error the client rejects on `res.ok` before
+ decoding.
+ """
try:
content = await request.json()
except json.JSONDecodeError:
return JSONResponse({"error": "invalid json"}, status_code=400)
kind = content.get("type")
- density_client_id: str | None = None
- density_seq: int | None = None
- if kind == "density_view":
- density_client_id, density_seq = _mark_latest_density(content)
-
with _FIGURE_LOCK:
- store = live_store()
- fig = store.figure
+ fig = live_figure()
if kind == "density_view":
try:
- if _density_is_stale(density_client_id or "default", density_seq):
- return _response(
- {
- "type": "density_update",
- "seq": content.get("seq"),
- "trace": content.get("trace"),
- "stale": True,
- "traces": [],
- }
- )
- update, buffers = _live_density_view(
- store,
+ update, buffers = fig.density_view(
int(content["trace"]),
float(content["x0"]),
float(content["x1"]),
@@ -363,7 +204,7 @@ async def drilldown_endpoint(request: Request) -> JSONResponse:
)
except (KeyError, ValueError, IndexError):
return JSONResponse({"error": "bad density_view request"}, status_code=400)
- return _response(
+ return _frame_response(
{
"type": "density_update",
"seq": content.get("seq"),
@@ -380,17 +221,18 @@ async def drilldown_endpoint(request: Request) -> JSONResponse:
int(content.get("index", -1)),
None if dseq is None else int(dseq),
)
- return _response({"type": "pick_result", "seq": content.get("seq"), "row": row})
+ return _frame_response({"type": "pick_result", "seq": content.get("seq"), "row": row})
if kind in {"select", "select_clear"}:
- return _response({"type": "selection", "traces": [], "total": 0})
+ return _frame_response({"type": "selection", "traces": [], "total": 0})
return JSONResponse({"error": f"unsupported message type {kind!r}"}, status_code=400)
def live_drilldown_html() -> str:
- fig = colored_scatter_figure()
- spec, blob = fig.build_payload()
+ fig = live_figure()
+ with _FIGURE_LOCK:
+ spec, blob = fig.build_payload()
spec_js = json.dumps(spec).replace("", "<\\/")
b64 = _b64(blob)
route = LIVE_DRILLDOWN_ROUTE
@@ -412,276 +254,14 @@ def live_drilldown_html() -> str: