Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/reflex/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.states
.web
.web/
.states/
*.db
Expand Down
22 changes: 22 additions & 0 deletions examples/reflex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,25 @@ points; box-select to cross-filter the histogram; press **go live** to stream.

The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in
[`rxconfig.py`](rxconfig.py).

## Interaction contract checks

Section 1's badges are event counters, and its click/select handlers
deliberately republish the cloud behind its stable token (the title's
`handler revision`). Together they make the wrapper's restore contract
manually verifiable:

1. Box-select a large area. The `select` readout shows the exact total, the
bounded JSON row count, and `truncated`; the §2 histogram cross-filters.
The cloud must keep both its viewport and its selection highlight across
the republish, and the selection counter must increment exactly once.
2. Zoom until density drills into exact points, then click one. The `click`
readout shows its canonical row ID, f64 data coordinates, and active
keyboard modifiers; the click counter must increment exactly once.
3. Focus a point and press Enter or Space. Keyboard activation must produce
the same click readout contract as pointer activation.
4. Clear the selection. The histogram returns to all points and the select
counter increments exactly once again.

A runaway counter or a viewport/selection reset after any of these reveals a
republish feedback loop or a restore regression.
72 changes: 54 additions & 18 deletions examples/reflex/xy_reflex_demo/xy_reflex_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ class Demo(rx.State):
# §1 semantic events
hovered: dict = {}
clicked: dict = {}
click_events: int = 0
select_events: int = 0
# Click/select handlers bump this and the cloud's title reads it, so every
# event deliberately republishes the source figure behind its stable
# token. The wrapper must keep the viewport and selection across that
# republish without re-dispatching events (no feedback loop) — the
# counters above make a violation visible as a runaway count.
interaction_revision: int = 0
# §2 state-driven + cross-filter
bins: int = 60
sel_active: bool = False
Expand All @@ -146,7 +154,10 @@ def cloud(self) -> xy.Chart:
xy.interaction_config(hover=True, click=True),
xy.x_axis(label="feature A"),
xy.y_axis(label="feature B"),
title=f"{POINTS // 1_000_000}M points, drillable",
title=(
f"{POINTS // 1_000_000}M points, drillable · "
f"handler revision {self.interaction_revision}"
),
width="100%",
height=460,
)
Expand Down Expand Up @@ -210,21 +221,36 @@ def detail(self) -> xy.Chart:
)

@rx.event
def on_hover(self, row: dict):
self.hovered = row
def on_hover(self, event: reflex_xy.PointHoverEvent):
# v1 point envelope: canonical_row_id + f64 data coordinates.
self.hovered = event.get("data", {})

@rx.event
def on_click(self, row: dict):
self.clicked = row
def on_click(self, event: reflex_xy.PointClickEvent):
self.click_events += 1
self.interaction_revision += 1
modifiers = event.get("modifiers", {})
self.clicked = {
"row": event.get("canonical_row_id"),
**event.get("data", {}),
"modifiers": ",".join(k for k, v in modifiers.items() if v) or "none",
}

@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"])
def on_select(self, event: reflex_xy.SelectEndEvent):
self.select_events += 1
self.interaction_revision += 1
selection = event.get("selection", {})
total = int(selection.get("total_count") or 0)
bounds = selection.get("data_bounds") or {}
if total and bounds.get("x0") is not None:
self.sel_x0 = float(bounds["x0"])
self.sel_x1 = float(bounds["x1"])
self.sel_active = True
self.select_note = f"{total:,} points selected"
self.select_note = (
f"{total:,} selected · {len(selection.get('rows', [])):,} rows in JSON · "
f"truncated={bool(selection.get('truncated'))}"
)
else:
self.sel_active = False
self.select_note = "selection cleared"
Expand All @@ -234,11 +260,13 @@ def set_bins(self, value: list[int | float]):
self.bins = int(value[0])

@rx.event
def on_view(self, view: dict):
# `view` is the small view-change payload: {x0, x1, y0, y1, ...}. Store
# the window; the `detail` figure var depends on it and recomputes.
self.view_x0 = float(view.get("x0", 0.0))
self.view_x1 = float(view.get("x1", 0.0))
def on_view(self, event: reflex_xy.ViewChangeEvent):
# `event` is the v1 view-change envelope; `x_domain` is the reported
# [x0, x1] window (debounced by the wrapper). Store the window; the
# `detail` figure var depends on it and recomputes.
x_domain = event.get("x_domain") or [0.0, 0.0]
self.view_x0 = float(x_domain[0])
self.view_x1 = float(x_domain[1])
self.view_ready = True
x, _ = _scan(120_000)
self.visible = int(((x >= self.view_x0) & (x <= self.view_x1)).sum())
Expand Down Expand Up @@ -419,7 +447,9 @@ def index() -> rx.Component:
section(
"1 · Live figure var + events",
"A 1M-point drillable scatter from an @reflex_xy.figure method. "
"Zoom to drill density into exact points; hover, click, and box-select.",
"Zoom to drill density into exact points; hover, click, and box-select. "
"Click and select handlers republish the chart itself (the title's "
"revision) — viewport and selection must survive each republish.",
rx.vstack(
cloud_view(),
kv(
Expand All @@ -434,10 +464,16 @@ def index() -> rx.Component:
"click",
rx.cond(
Demo.clicked.length() > 0,
f"x={Demo.clicked['x']} y={Demo.clicked['y']}",
f"row {Demo.clicked['row']} · x={Demo.clicked['x']} "
f"y={Demo.clicked['y']} · modifiers={Demo.clicked['modifiers']}",
"zoom in to drill, then click a point",
),
),
kv(
"events",
f"{Demo.click_events} clicks · {Demo.select_events} selections · "
f"republish revision {Demo.interaction_revision}",
),
width="100%",
spacing="3",
),
Expand Down
47 changes: 45 additions & 2 deletions js/src/45_lod.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,25 @@ function lodApplyDrill(view, g, upd, buffers) {
d.trace = { ...g.trace, style: upd.style || g.trace.style || {} };
d.xAxis = g.xAxis;
d.yAxis = g.yAxis;
const xs = view._asF32(buffers[upd.x.buf]);
const ys = view._asF32(buffers[upd.y.buf]);
gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf);
gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.x.buf]), gl.STATIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, xs, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, d.yBuf);
gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.y.buf]), gl.STATIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, ys, 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; // subset version — echoed with picks, gates selections
d.selActive = false; // drilled subset changed; old mask indices are stale
// §34 selection continuity: the swapped subset invalidates the old mask
// *indices*, but the brush geometry is still authoritative — re-derive the
// mask from the decoded window coordinates so the highlight never blinks
// out across a pan/zoom re-drill. The kernel's next selection reply (the
// adapter's resync, or a fresh drag) remains the authoritative overwrite.
lodRestoreBrushMask(view, d, xs, ys);
// The point under the cursor is a different row now; a cached tooltip for
// the same index would silently show the old point's values (§16).
view._hoverId = -1;
Expand Down Expand Up @@ -341,6 +349,38 @@ function lodApplyDrill(view, g, upd, buffers) {
g._drillDiedInsideWin = false;
}

// Provisional selection mask for a freshly shipped drill subset, derived
// locally from the retained data-space brush (box or lasso). Exact for range
// predicates — the same containment test the kernel runs (§34 Tier A) — so
// the eventual kernel mask is a no-op overwrite, not a correction.
function lodRestoreBrushMask(view, d, xs, ys) {
const b = view._lastBrush;
if (!b || !d.n) return;
const ox = d.xMeta.offset, sx = d.xMeta.scale || 1;
const oy = d.yMeta.offset, sy = d.yMeta.scale || 1;
const mask = new Float32Array(d.n);
if (b.mode === "box") {
for (let i = 0; i < d.n; i++) {
const x = xs[i] / sx + ox, y = ys[i] / sy + oy;
if (x >= b.x0 && x <= b.x1 && y >= b.y0 && y <= b.y1) mask[i] = 1;
}
} else if (b.mode === "poly" && Array.isArray(b.points) && b.points.length >= 3) {
const pts = b.points;
for (let i = 0; i < d.n; i++) {
const x = xs[i] / sx + ox, y = ys[i] / sy + oy;
let hit = false;
for (let a = 0, z = pts.length - 1; a < pts.length; z = a++) {
const [xa, ya] = pts[a], [xz, yz] = pts[z];
if ((ya > y) !== (yz > y) && x < ((xz - xa) * (y - ya)) / (yz - ya) + xa) hit = !hit;
}
if (hit) mask[i] = 1;
}
} else {
return;
}
view._applySelMask(d, mask);
}

function lodDropDrill(view, g) {
const d = g.drill;
if (!d) return;
Expand All @@ -357,6 +397,9 @@ function lodDropDrill(view, g) {
g._drillDiedInsideWin = false;
view._hoverId = -1; // drilled indices are dead; don't reuse a cached row
view._lastRow = null;
// The freed drill may have been the only pickable geometry (a density-only
// chart): retract the modebar Select trigger with the capability.
view._updatePickable();
}

// A density update arrived while drilled: don't drop the marks instantly
Expand Down
14 changes: 12 additions & 2 deletions js/src/50_chartview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1749,8 +1749,18 @@ class ChartView {
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();
this._updatePickable();
}

// Recompute point-pickability from the current GPU traces and reflect it in
// the modebar. Density traces count only while drilled to exact points
// (§5/§34), so this must re-run on every drill state change — the Select
// trigger tracks the capability instead of freezing at construction time.
_updatePickable() {
this._pickable = this.gpuTraces.some(
(t) => markOf(t.trace.kind).pointPick && (t.tier !== "density" || t.drill));
if (this._pickable && !this.pickFbo) this._initPickTarget();
this._syncModebarSelect?.();
}

_prog(key, vs, fs) {
Expand Down
79 changes: 75 additions & 4 deletions js/src/53_interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,42 @@ Object.assign(ChartView.prototype, {

_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") {
const activate = e.key === "Enter" || e.key === " ";
if (direction === undefined && e.key !== "Home" && e.key !== "End" && e.key !== "Escape"
&& !activate) {
return;
}
if (activate) {
if (!this._interactionFlag("click") || !this._hoverTarget) return;
e.preventDefault();
const hit = this._hoverTarget;
const rect = this.canvas.getBoundingClientRect();
const clientX = this._lastHoverXY?.clientX ?? rect.left;
const clientY = this._lastHoverXY?.clientY ?? rect.top;
const screen = { x: clientX - rect.left, y: clientY - rect.top };
const modifiers = {
shift: e.shiftKey === true,
alt: e.altKey === true,
ctrl: e.ctrlKey === true,
meta: e.metaKey === true,
};
const detail = {
row: this._localRow ? this._localRow(hit) : null,
trace: hit.trace,
index: hit.index,
screen,
modifiers,
view: this._eventView("click"),
};
this._dispatchChartEvent("click", detail);
if (this.comm) {
const msg = { type: "click", trace: hit.trace, index: hit.index, screen, modifiers };
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);
}
return;
}
if (e.key === "Escape") {
Expand Down Expand Up @@ -422,7 +457,18 @@ Object.assign(ChartView.prototype, {
};
this._dispatchChartEvent("click", detail);
if (hit && this.comm) {
const msg = { type: "click", trace: hit.trace, index: hit.index };
const msg = {
type: "click",
trace: hit.trace,
index: hit.index,
screen: { x: cssX, y: cssY },
modifiers: {
shift: e.shiftKey === true,
alt: e.altKey === true,
ctrl: e.ctrlKey === true,
meta: e.metaKey === true,
},
};
const g = hit.g;
if (g && g.tier === "density" && g.drill && g.drill.seq !== undefined) {
msg.drill_seq = g.drill.seq;
Expand Down Expand Up @@ -614,6 +660,11 @@ Object.assign(ChartView.prototype, {
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 };
// Data-space brush geometry survives drill swaps (§34): a re-drill ships a
// new subset whose mask indices are unknown until the kernel replies, but
// the brush itself stays authoritative — 45_lod re-derives a provisional
// mask from it so the highlight never blinks out on pan/zoom.
this._lastBrush = { mode: "box", x0, x1, y0, y1 };
this._broadcastLinkedSelection({ range });
this._dispatchChartEvent("brush", { range, view: this._eventView("brush") });
if (this.comm) {
Expand All @@ -628,6 +679,7 @@ Object.assign(ChartView.prototype, {
const polygon = points.map((point) => [point[0], point[1]]);
if (!polygon.every((point) => point.every(Number.isFinite))) return;
this._lassoPolygon = polygon;
this._lastBrush = { mode: "poly", points: polygon }; // see _sendSelect
this._broadcastLinkedSelection({ polygon });
this._renderLassoSelection();
this._dispatchChartEvent("brush", {
Expand Down Expand Up @@ -732,6 +784,7 @@ Object.assign(ChartView.prototype, {
if (g.drill) g.drill.selActive = false;
}
this._selectionCount = 0;
this._lastBrush = null;
if (opts.broadcast !== false) this._broadcastLinkedSelection({ clear: true });
if (opts.dispatch !== false) {
if (this._interactionFlag("select", true)) {
Expand Down Expand Up @@ -913,8 +966,12 @@ Object.assign(ChartView.prototype, {
zoomTrigger.setAttribute("aria-haspopup", "menu");
zoomTrigger.setAttribute("aria-expanded", "false");
}
const canSelect = this._pickable
&& this._interactionFlag("brush", true)
// Pickability is dynamic for density traces (§5: drill-in grants it,
// drill-out revokes it), so the Select trigger is built whenever the
// interaction flags allow selection and its *visibility* tracks
// `_pickable` via _syncModebarSelect below — the button must not freeze
// at whatever pickability held when the toolbar was first built.
const canSelect = this._interactionFlag("brush", true)
&& this._interactionFlag("select", true);
let selectTrigger = null;
let selectIndicator = null;
Expand Down Expand Up @@ -1199,6 +1256,20 @@ Object.assign(ChartView.prototype, {
setSelectMenuOpen(false);
setExportMenuOpen(false);
};
// Show/hide the Select trigger to match live pickability. Losing the
// capability mid-session (drill-out) also closes the menu and drops any
// active select drag mode back to the default, so the cursor never
// advertises a selection gesture the chart can't honor.
this._syncModebarSelect = () => {
if (!selectTrigger) return;
const on = Boolean(this._pickable);
if (!on) {
setSelectMenuOpen(false);
if (this.dragMode.startsWith("select")) this._setDragMode("pan");
}
selectTrigger.style.display = on ? "flex" : "none";
};
this._syncModebarSelect();
this._listen(document, "pointerdown", (e) => {
if (this._zoomMenuOpen && !bar.contains(e.target)) setZoomMenuOpen(false);
if (this._selectMenuOpen && !bar.contains(e.target)) setSelectMenuOpen(false);
Expand Down
Loading
Loading