Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

* State-kind reactive accessors (e.g., `map.bounds()`, `plot.camera()`, `grid.view_config()`) now attach to the widget at render time instead of on first read. An interaction made before the accessor's first read -- for example, a pan or selection made before a chat handler or LLM tool first reads the accessor -- is now reflected in that first read instead of being lost. Event-kind accessors (e.g., plotly's `selected()`/`clicked()`) still attach on first read; if their first reader runs late, read them once at startup in a `@reactive.effect` to attach immediately.
* State-kind reactive accessors (e.g., `map.bounds()`, `plot.camera()`, `grid.view_config()`) now attach to the widget at render time instead of on first read. An interaction made before the accessor's first read -- for example, a pan or selection made before a chat handler or LLM tool first reads the accessor -- is now reflected in that first read instead of being lost. Event-kind accessors (e.g., plotly's `clicked()`) still attach on first read; if their first reader runs late, read them once at startup in a `@reactive.effect` to attach immediately.
* `@render_plotly`'s `selected()` and `brushed()` (and therefore `selected_rows()`) are now state accessors: they attach at render, and when the event bridge binds, the browser also reports the plot's current selection, which plotly.js keeps on the graph div (trace `selectedpoints`, plus the region on the last layout selection). A box/lasso selection made before the accessor's first read -- or before the bind request reaches the browser -- is now reported instead of silently lost. Because state accessors don't reset on re-render, `selected()`/`brushed()` keep their value after a re-render until the new plot reports its own (initially empty) selection.
* shinywidgets now requires ipywidgets 8.1 or newer, and the bundled JavaScript widget manager (`@jupyter-widgets/html-manager`) was upgraded from 0.20 to 1.0. Together these fix a wide range of long-standing issues with ipywidgets 8 (see the bug fixes below). (#237, #259)
* shinywidgets now requires plotly >= 5.12 (the first release whose `FigureWidget` frontend supports ipywidgets 8). Constructing a plotly widget with an older plotly raises an error explaining how to upgrade, instead of rendering a blank output with no error. (#332)
* shinywidgets now requires shiny 1.9.0 or newer, which sends widget updates made outside the reactive graph (e.g., from a background thread) to the browser right away. (#377)
Expand Down
123 changes: 123 additions & 0 deletions js/src/plotly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,136 @@ export function bindPlotlyEvents(plotEl: HTMLElement, model: WidgetModel): void
if (!requested || !hasMethod<PlotlyEventEmitter, "on">(plotEl, "on")) return;
const bound = boundEvents.get(plotEl) ?? new Set<string>();
boundEvents.set(plotEl, bound);
let boundSelected = false;
for (const event of requested) {
if (bound.has(event)) continue;
bound.add(event);
if (event === "plotly_selected") boundSelected = true;
plotEl.on(event, (data?: any) => {
model.send({ shinywidgets: "event", event, ...eventPayload(event, data) }, {});
});
}
// A selection is state on the graph div (trace `selectedpoints`), not just
// an event: a lasso drawn before this bind (e.g. before an accessor's first
// read asked for the event) is still recoverable, so report the current
// selection as if plotly.js had just fired it. An empty payload is a
// no-data `plotly_selected`, which plotly.js itself sends for an empty
// selection and Python reads as "nothing selected".
if (boundSelected) {
model.send(
{ shinywidgets: "event", event: "plotly_selected", ...selectionSeed(plotEl) },
{},
);
}
}

// Rebuilds a plotly_selected payload from graph-div state: one entry per
// selected point, plus the region (box range or lasso vertices) plotly.js
// keeps on the last layout selection.
function selectionSeed(plotEl: HTMLElement): Record<string, unknown> {
const data = (plotEl as any).data;
if (!Array.isArray(data)) return {};
const points = data.flatMap((trace: any, curveNumber: number) => {
// jsonable(): a selection preconfigured from plotly.py arrives as a
// typed array, which Array.isArray rejects (and whose .map can't hold
// point objects)
const selected = jsonable(trace?.selectedpoints);
if (!Array.isArray(selected)) return [];
return (selected as number[]).map((i) => {
const entry: Record<string, unknown> = {
trace_index: curveNumber,
point_index: i,
};
for (const key of ["x", "y", "z", "customdata"]) {
let value = trace[key]?.[i];
Comment thread
Copilot marked this conversation as resolved.
Resolved
// A multicategory axis stores one array per category level (so the
// trace array's length is the level count, not the point count, and
// indexing it by sample is wrong); the coordinate combines each
// level's value at i
if (key !== "customdata" && Array.isArray(trace[key]) && Array.isArray(trace[key][0])) {
value = (trace[key] as any[]).map((level) => level?.[i]);
} else if (value === undefined && key !== "customdata") {
// Coordinates the trace doesn't carry explicitly (implicit x,
// bar x0/dx) are resolved on calcdata, matching the live event's
// point objects
value = calcCoord(plotEl, curveNumber, i, key, trace?.type);
}
if (value !== undefined) entry[key] = jsonable(value);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
return entry;
});
});
// An empty drag selection is still a selection: plotly.js reports it as
// points: [] plus the region. So is a preconfigured selectedpoints: []
// (plotly dims every point with unselected styling). No points, no
// region, and no trace supplying a selection array means nothing was
// ever selected (or a deselect), the no-data case.
const region = regionSeed(plotEl);
const anySelectionArray = data.some((trace: any) =>
Array.isArray(jsonable(trace?.selectedpoints)),
);
if (points.length === 0 && Object.keys(region).length === 0 && !anySelectionArray) {
return {};
}
return { points, ...region };
}

function regionSeed(plotEl: HTMLElement): Record<string, unknown> {
const selections = (plotEl as any)._fullLayout?.selections;
if (!Array.isArray(selections) || selections.length === 0) return {};
const last = selections[selections.length - 1];
// Selections on later subplots reference their axes (x2, y2, ...), and
// live events key the region the same way
const xref = typeof last.xref === "string" ? last.xref : "x";
const yref = typeof last.yref === "string" ? last.yref : "y";
// A box selection keeps its corners; the event reports each axis ascending
if (last.x0 !== undefined) {
const asc = (a: any, b: any) => (a <= b ? [a, b] : [b, a]);
return { range: { [xref]: asc(last.x0, last.x1), [yref]: asc(last.y0, last.y1) } };
}
// A lasso keeps its vertices as an SVG path: "M x,y L x,y ...". On date
// axes the values are date strings with "_" for the date/time separator;
// live lassoPoints restore the space.
if (typeof last.path === "string") {
const x: unknown[] = [];
const y: unknown[] = [];
const vertex = /[ML]([^MLZz]+)/g;
let m: RegExpExecArray | null;
while ((m = vertex.exec(last.path)) !== null) {
const sep = m[1].lastIndexOf(",");
if (sep < 0) continue;
x.push(lassoValue(m[1].slice(0, sep)));
y.push(lassoValue(m[1].slice(sep + 1)));
}
if (x.length > 0) return { lassoPoints: { [xref]: x, [yref]: y } };
}
return {};
}

// Resolved coordinates from calcdata, for the trace types where its layout
// is verified: scatter and bar align one calc point per data index
// (implicit x, x0/dx); scattergl keeps one calc entry whose .t holds typed
// coordinate arrays. Others don't align by sample index (histograms bin
// them) or are unverified, so their missing keys stay missing, as in live
// payloads.
function calcCoord(
plotEl: HTMLElement,
curveNumber: number,
i: number,
key: string,
type: string | undefined,
): unknown {
const cd = (plotEl as any).calcdata?.[curveNumber];
if (!Array.isArray(cd)) return undefined;
if (type === "scattergl") return cd[0]?.t?.[key]?.[i];
if (type === "scatter" || type === "bar") return cd[i]?.[key];
return undefined;
}

function lassoValue(token: string): unknown {
const t = token.trim();
const n = Number(t);
return Number.isNaN(n) ? t.replace("_", " ") : n;
}

// Shaped like R plotly's event_data(): one entry per point, with its trace
Expand Down
11 changes: 9 additions & 2 deletions shinywidgets/_plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
browser to forward the events it needs (`{"shinywidgets": "bind"}`); the
browser then sends each one as a custom message (`{"shinywidgets": "event"}`)
with a payload shaped like R plotly's `event_data()`.

A selection is state, not just an event: plotly.js keeps it on the graph div
(trace `selectedpoints`, plus the region on the last layout selection), so on
bind the bridge also rebuilds and sends the current selection (an empty
selection goes as a no-data `plotly_selected`, the same message plotly.js
sends for one). That lets `selected()`/`brushed()` be state accessors that
attach at render, so a lasso drawn before their first read isn't lost.
"""

from __future__ import annotations
Expand Down Expand Up @@ -145,7 +152,7 @@ def relayout_of(content: Dict[str, Any]) -> object:
)
SELECTED = AccessorSpec(
name="selected",
kind="event",
kind="state",
attach=bridge_attach("plotly_selected", points_of, (DESELECT,)),
payload=points_frame,
)
Expand All @@ -158,7 +165,7 @@ def relayout_of(content: Dict[str, Any]) -> object:
)
BRUSHED = AccessorSpec(
name="brushed",
kind="event",
kind="state",
attach=bridge_attach("plotly_selected", region_of, (DESELECT,)),
)
BRUSHING = AccessorSpec(
Expand Down
6 changes: 5 additions & 1 deletion shinywidgets/_render_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3418,7 +3418,11 @@ class render_plotly(render_widget_base[PlotlyValueT, FigureWidget]):
state, not an error, so check for it (``req(pts is not None)``; not
``req(pts)``, since a data frame has no truth value).
* A re-render (e.g. when the render function reads an input) gives the user
a new plot, so they all start over at ``None``.
a new plot. ``clicked()``, ``selecting()``, ``brushing()``, and
``relayout()`` start over at ``None``; ``selected()`` and ``brushed()``
follow the displayed plot's selection instead, keeping their value until
the new plot reports its own (initially empty, so they become ``None``
once it does).
* Repeating an interaction (clicking the same point twice) isn't a change,
so readers don't re-run.
* A double-click on the plot background clears ``clicked()``, and a
Expand Down
2 changes: 1 addition & 1 deletion shinywidgets/static/output.js

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions tests/playwright/plotly_selection_seed/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import annotations

import pandas as pd
import plotly.express as px
from shiny import App, reactive, render, ui
from shinywidgets import output_widget, render_plotly

# The accessor reads the tests assert on are gated behind an action button,
# and NOTHING reads them at startup (an ungated read would attach the
# accessor early, defeating the scenario). A selection made before the
# button click exercises the eager state attach and the seed on bridge bind
# (see the tests).

df = pd.DataFrame({"x": [1, 2, 3, 4, 5], "y": [1, 4, 9, 16, 25], "g": list("aabbb")})

app_ui = ui.page_fluid(
ui.input_action_button("read_selection", "Read selection"),
ui.output_text("selected"),
ui.output_text("selected_rows"),
ui.output_text("brushed"),
ui.card(output_widget("plot"), height="450px"),
)


def server(input, output, session):
@render_plotly
def plot():
fig = px.scatter(df, x="x", y="y", color="g", custom_data=[df.index])
fig.update_traces(marker_size=14)
fig.update_layout(dragmode="select")
return fig

@render.text
@reactive.event(input.read_selection)
def selected():
pts = plot.selected()
if pts is None:
return "None"
return ";".join(f"{r.trace_index},{r.point_index}" for r in pts.itertuples())

@render.text
@reactive.event(input.read_selection)
def selected_rows():
return ",".join(str(x) for x in plot.selected_rows(df)["x"])

@render.text
@reactive.event(input.read_selection)
def brushed():
b = plot.brushed()
return "None" if b is None else ",".join(sorted(b))


app = App(app_ui, server)
136 changes: 136 additions & 0 deletions tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
from __future__ import annotations

from playwright.sync_api import Page, expect
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from tests.playwright.conftest import assert_no_unexpected_errors, collect_errors

# Page coordinates of a data point, via plotly.js's axis conversions (same
# helper as tests/playwright/plotly_accessors).
POINT_JS = """([id, x, y]) => {
const gd = document.querySelector(`#${id} .js-plotly-plot`);
const L = gd._fullLayout;
const box = gd.querySelector(".nsewdrag").getBoundingClientRect();
return [box.left + L.xaxis.d2p(x), box.top + L.yaxis.d2p(y)];
}"""

# True once the plot holds a selection (trace selectedpoints is also where
# the bridge's seed reads it from). Confirms a drag gesture actually landed:
# the plot can be visible before it accepts drags.
SELECTED_STATE_JS = """(id) => {
const gd = document.querySelector(`#${id} .js-plotly-plot`);
if (!gd || !gd._fullLayout) return false;
return (gd.data || []).some(
(t) => Array.isArray(t.selectedpoints) && t.selectedpoints.length > 0
);
}"""

DESELECTED_STATE_JS = SELECTED_STATE_JS.replace("return (", "return !(")


def wait_for_sync(page: Page) -> None:
"""Wait until a selection/deselection made in the browser has reached
the server, WITHOUT reading an accessor (an ungated accessor read would
attach it early, defeating the late-first-read scenario). plotly.js
emits plotly_selected a few hundred ms after mouseup (held back for
double-click disambiguation), and client->server messages are ordered,
so once the delay has passed, the next message (the button click) is
processed after the selection message."""
page.wait_for_timeout(700)


def point(page: Page, x: float, y: float, id: str = "plot") -> tuple[float, float]:
px, py = page.evaluate(POINT_JS, [id, x, y])
return px, py


def drag(page: Page, start: tuple[float, float], end: tuple[float, float]) -> None:
page.mouse.move(*start)
page.mouse.down()
page.mouse.move((start[0] + end[0]) / 2, (start[1] + end[1]) / 2, steps=5)
page.mouse.move(*end, steps=5)
page.mouse.up()


# The three points of df with x in [1.5, 4.5]: trace 0's point 1 and trace
# 1's points 0 and 1 (df rows 1, 2, 3)
SELECTED_POINTS = "0,1;1,0;1,1"
SELECTED_ROWS = "2,3,4"
SELECTED_COUNT = "3"


def box_select(page: Page, id: str = "plot") -> None:
"""Box-select the points with x in [1.5, 4.5], retrying until the
selection lands on the graph div."""
for _ in range(10):
drag(page, point(page, 1.5, 26, id=id), point(page, 4.5, 0, id=id))
try:
page.wait_for_function(SELECTED_STATE_JS, arg=id, timeout=3000)
return
except PlaywrightTimeoutError:
pass
page.wait_for_function(SELECTED_STATE_JS, arg=id, timeout=30000)


def goto_plot(page: Page, local_app) -> None:
page.goto(local_app.url)
expect(page.locator("#plot .js-plotly-plot")).to_be_visible(timeout=30000)
# Nothing reads the accessors until the button is clicked (any ungated
# read would attach them early), so the gated outputs are still empty
expect(page.locator("#selected")).to_have_text("")
expect(page.locator("#selected_rows")).to_have_text("")
expect(page.locator("#brushed")).to_have_text("")


def test_selection_before_first_read_is_reported(page: Page, local_app) -> None:
"""A box selection made before the accessor's (gated) first read is
reflected in that read. Nothing in the app reads the accessors before
the button click, so the first read is genuinely late: pre-change
(kind="event", lazy attach on first read) the selection below fired
into a nonexistent listener and this read returned None.

selected()/brushed() are kind="state" accessors: they attach at render,
and the bridge seeds the plot's current selection when the bind reaches
the browser (js/src/plotly.ts), so the selection is captured whether it
preceded the bind (seed) or followed it (live forwarding).
"""
errors, failed_requests = collect_errors(page)
goto_plot(page, local_app)

# Select BEFORE the gated first read of selected()/selected_rows()/
# brushed(), and wait for the selection to reach the server before
# clicking (see wait_for_sync)
box_select(page)
wait_for_sync(page)

page.locator("#read_selection").click()
expect(page.locator("#selected")).to_have_text(SELECTED_POINTS, timeout=30000)
expect(page.locator("#selected_rows")).to_have_text(SELECTED_ROWS)
expect(page.locator("#brushed")).to_have_text("range")

assert_no_unexpected_errors(errors, failed_requests)


def test_deselect_after_read_clears_selection(page: Page, local_app) -> None:
"""A deselect (double-click on the plot background) after the gated read
clears the accessors: a re-read reports no selection."""
errors, failed_requests = collect_errors(page)
goto_plot(page, local_app)

box_select(page)
wait_for_sync(page)
page.locator("#read_selection").click()
expect(page.locator("#selected")).to_have_text(SELECTED_POINTS, timeout=30000)
expect(page.locator("#brushed")).to_have_text("range")

# Double-click the plot background (x=1, y=25 is no data point), wait
# for the deselect to reach the server, then re-read: cleared
page.mouse.dblclick(*point(page, 1, 25))
page.wait_for_function(DESELECTED_STATE_JS, arg="plot", timeout=30000)
wait_for_sync(page)

page.locator("#read_selection").click()
expect(page.locator("#selected")).to_have_text("None", timeout=30000)
expect(page.locator("#selected_rows")).to_have_text("")
expect(page.locator("#brushed")).to_have_text("None")

assert_no_unexpected_errors(errors, failed_requests)
Loading
Loading