diff --git a/CHANGELOG.md b/CHANGELOG.md index a2154f74..758ce8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 36484ac5..8876f7bf 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -93,13 +93,136 @@ export function bindPlotlyEvents(plotEl: HTMLElement, model: WidgetModel): void if (!requested || !hasMethod(plotEl, "on")) return; const bound = boundEvents.get(plotEl) ?? new Set(); 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 { + 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 = { + trace_index: curveNumber, + point_index: i, + }; + for (const key of ["x", "y", "z", "customdata"]) { + let value = trace[key]?.[i]; + // 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); + } + 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 { + 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 diff --git a/shinywidgets/_plotly.py b/shinywidgets/_plotly.py index 6bebe3fc..7432c30f 100644 --- a/shinywidgets/_plotly.py +++ b/shinywidgets/_plotly.py @@ -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 @@ -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, ) @@ -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( diff --git a/shinywidgets/_render_widget.py b/shinywidgets/_render_widget.py index 7482f4c2..4a698725 100644 --- a/shinywidgets/_render_widget.py +++ b/shinywidgets/_render_widget.py @@ -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 diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 2f1b1b6c..ede1902c 100644 --- a/shinywidgets/static/output.js +++ b/shinywidgets/static/output.js @@ -106,7 +106,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _jup \***********************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"bindPlotlyEvents\": () => (/* binding */ bindPlotlyEvents),\n/* harmony export */ \"findPlotlyGraphDiv\": () => (/* binding */ findPlotlyGraphDiv),\n/* harmony export */ \"isPlotlyBindRequest\": () => (/* binding */ isPlotlyBindRequest),\n/* harmony export */ \"requestPlotlyEvents\": () => (/* binding */ requestPlotlyEvents),\n/* harmony export */ \"waitForPlotlyAfterPlot\": () => (/* binding */ waitForPlotlyAfterPlot),\n/* harmony export */ \"waitForPlotlyGraphDiv\": () => (/* binding */ waitForPlotlyGraphDiv),\n/* harmony export */ \"waitForPlotlyReadyToReveal\": () => (/* binding */ waitForPlotlyReadyToReveal)\n/* harmony export */ });\nconst plotlyAfterPlotTimeoutMs = 5000;\nfunction findPlotlyGraphDiv(root) {\n const plotlyEl = root.matches(\".js-plotly-plot\")\n ? root\n : root.querySelector(\".js-plotly-plot\");\n return plotlyEl instanceof HTMLElement ? plotlyEl : null;\n}\n// The events requested for each model, applied to every view of it\nconst requestedEvents = new WeakMap();\n// The events already bound on each graph div\nconst boundEvents = new WeakMap();\nfunction isPlotlyBindRequest(content) {\n return (!!content &&\n content.shinywidgets === \"bind\" &&\n Array.isArray(content.events));\n}\nasync function requestPlotlyEvents(model, events) {\n var _a, _b;\n const requested = (_a = requestedEvents.get(model)) !== null && _a !== void 0 ? _a : new Set();\n events.forEach((e) => requested.add(e));\n requestedEvents.set(model, requested);\n // Views that render later bind in bindPlotlyEvents(), via plotlyBehavior\n for (const viewPromise of Object.values((_b = model.views) !== null && _b !== void 0 ? _b : {})) {\n try {\n const view = await viewPromise;\n // The view can exist well before Plotly draws its graph div into it\n // (e.g., while its JS is still loading); wait for the draw rather than\n // skipping the view, or the events would never get bound.\n const plotEl = await waitForPlotlyGraphDiv(view.el);\n if (!plotEl)\n continue;\n await waitForPlotlyAfterPlot(plotEl);\n bindPlotlyEvents(plotEl, model);\n }\n catch (err) {\n console.error(\"Error binding plotly events:\", err);\n }\n }\n}\nconst plotlyGraphDivTimeoutMs = 30000;\n// Like findPlotlyGraphDiv, but waits (a bounded time) for the graph div to\n// appear: Plotly draws asynchronously after the view is displayed, and a\n// caller that gives up before then never gets another chance to bind.\nfunction waitForPlotlyGraphDiv(root, timeoutMs = plotlyGraphDivTimeoutMs) {\n const existing = findPlotlyGraphDiv(root);\n if (existing)\n return Promise.resolve(existing);\n return new Promise((resolve) => {\n const finish = (el) => {\n observer.disconnect();\n window.clearTimeout(timer);\n resolve(el);\n };\n const observer = new MutationObserver(() => {\n const el = findPlotlyGraphDiv(root);\n if (el)\n finish(el);\n });\n const timer = window.setTimeout(() => finish(null), timeoutMs);\n observer.observe(root, { childList: true, subtree: true });\n });\n}\nfunction bindPlotlyEvents(plotEl, model) {\n var _a;\n const requested = requestedEvents.get(model);\n if (!requested || !hasMethod(plotEl, \"on\"))\n return;\n const bound = (_a = boundEvents.get(plotEl)) !== null && _a !== void 0 ? _a : new Set();\n boundEvents.set(plotEl, bound);\n for (const event of requested) {\n if (bound.has(event))\n continue;\n bound.add(event);\n plotEl.on(event, (data) => {\n model.send(Object.assign({ shinywidgets: \"event\", event }, eventPayload(event, data)), {});\n });\n }\n}\n// Shaped like R plotly's event_data(): one entry per point, with its trace\n// index, its index within the trace, coordinates, and customdata.\nfunction eventPayload(event, data) {\n if (event === \"plotly_relayout\")\n return { relayout: data };\n const out = {};\n if (!data)\n return out;\n if (Array.isArray(data.points))\n out.points = data.points.flatMap(pointEntries);\n if (data.range)\n out.range = data.range;\n if (data.lassoPoints)\n out.lassoPoints = data.lassoPoints;\n return out;\n}\nfunction pointEntries(p) {\n var _a;\n // Aggregated traces (e.g. histograms) report every underlying point\n const aggregated = Array.isArray(p.pointNumbers);\n const indices = aggregated ? p.pointNumbers : [(_a = p.pointNumber) !== null && _a !== void 0 ? _a : p.pointIndex];\n return indices.map((i) => {\n var _a, _b;\n const entry = { trace_index: p.curveNumber, point_index: i };\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n const value = aggregated ? (_b = (_a = p.data) === null || _a === void 0 ? void 0 : _a[key]) === null || _b === void 0 ? void 0 : _b[i] : p[key];\n if (value !== undefined)\n entry[key] = jsonable(value);\n }\n return entry;\n });\n}\n// plotly.js decodes plotly.py's binary arrays into typed arrays, which\n// JSON.stringify would turn into {\"0\": ..., \"1\": ...} objects.\nfunction jsonable(value) {\n return ArrayBuffer.isView(value) ? Array.from(value) : value;\n}\nasync function waitForPlotlyReadyToReveal(plotEl, dispatchResize) {\n var _a;\n const plotly = window.Plotly;\n await waitForPlotlyAfterPlot(plotEl);\n if ((_a = plotly === null || plotly === void 0 ? void 0 : plotly.Plots) === null || _a === void 0 ? void 0 : _a.resize) {\n try {\n const afterResize = waitForPlotlyAfterPlot(plotEl);\n await plotly.Plots.resize(plotEl);\n await afterResize;\n }\n catch (err) {\n console.error(\"Error resizing Plotly widget before reveal:\", err);\n }\n }\n dispatchResize();\n}\nfunction waitForPlotlyAfterPlot(plotEl) {\n // If Plotly has already completed its render, _fullLayout will be populated.\n // This avoids waiting for an event that already fired before we subscribed.\n if (plotEl._fullLayout) {\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const handler = () => {\n cleanup();\n resolve();\n };\n const cleanup = () => {\n if (hasMethod(plotEl, \"removeListener\")) {\n plotEl.removeListener(\"plotly_afterplot\", handler);\n }\n window.clearTimeout(timeoutId);\n };\n const timeoutId = window.setTimeout(() => {\n cleanup();\n resolve();\n }, plotlyAfterPlotTimeoutMs);\n if (hasMethod(plotEl, \"once\")) {\n plotEl.once(\"plotly_afterplot\", handler);\n return;\n }\n if (hasMethod(plotEl, \"on\")) {\n plotEl.on(\"plotly_afterplot\", handler);\n return;\n }\n cleanup();\n resolve();\n });\n}\nfunction hasMethod(x, key) {\n return !!x && typeof x[key] === \"function\";\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/plotly.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"bindPlotlyEvents\": () => (/* binding */ bindPlotlyEvents),\n/* harmony export */ \"findPlotlyGraphDiv\": () => (/* binding */ findPlotlyGraphDiv),\n/* harmony export */ \"isPlotlyBindRequest\": () => (/* binding */ isPlotlyBindRequest),\n/* harmony export */ \"requestPlotlyEvents\": () => (/* binding */ requestPlotlyEvents),\n/* harmony export */ \"waitForPlotlyAfterPlot\": () => (/* binding */ waitForPlotlyAfterPlot),\n/* harmony export */ \"waitForPlotlyGraphDiv\": () => (/* binding */ waitForPlotlyGraphDiv),\n/* harmony export */ \"waitForPlotlyReadyToReveal\": () => (/* binding */ waitForPlotlyReadyToReveal)\n/* harmony export */ });\nconst plotlyAfterPlotTimeoutMs = 5000;\nfunction findPlotlyGraphDiv(root) {\n const plotlyEl = root.matches(\".js-plotly-plot\")\n ? root\n : root.querySelector(\".js-plotly-plot\");\n return plotlyEl instanceof HTMLElement ? plotlyEl : null;\n}\n// The events requested for each model, applied to every view of it\nconst requestedEvents = new WeakMap();\n// The events already bound on each graph div\nconst boundEvents = new WeakMap();\nfunction isPlotlyBindRequest(content) {\n return (!!content &&\n content.shinywidgets === \"bind\" &&\n Array.isArray(content.events));\n}\nasync function requestPlotlyEvents(model, events) {\n var _a, _b;\n const requested = (_a = requestedEvents.get(model)) !== null && _a !== void 0 ? _a : new Set();\n events.forEach((e) => requested.add(e));\n requestedEvents.set(model, requested);\n // Views that render later bind in bindPlotlyEvents(), via plotlyBehavior\n for (const viewPromise of Object.values((_b = model.views) !== null && _b !== void 0 ? _b : {})) {\n try {\n const view = await viewPromise;\n // The view can exist well before Plotly draws its graph div into it\n // (e.g., while its JS is still loading); wait for the draw rather than\n // skipping the view, or the events would never get bound.\n const plotEl = await waitForPlotlyGraphDiv(view.el);\n if (!plotEl)\n continue;\n await waitForPlotlyAfterPlot(plotEl);\n bindPlotlyEvents(plotEl, model);\n }\n catch (err) {\n console.error(\"Error binding plotly events:\", err);\n }\n }\n}\nconst plotlyGraphDivTimeoutMs = 30000;\n// Like findPlotlyGraphDiv, but waits (a bounded time) for the graph div to\n// appear: Plotly draws asynchronously after the view is displayed, and a\n// caller that gives up before then never gets another chance to bind.\nfunction waitForPlotlyGraphDiv(root, timeoutMs = plotlyGraphDivTimeoutMs) {\n const existing = findPlotlyGraphDiv(root);\n if (existing)\n return Promise.resolve(existing);\n return new Promise((resolve) => {\n const finish = (el) => {\n observer.disconnect();\n window.clearTimeout(timer);\n resolve(el);\n };\n const observer = new MutationObserver(() => {\n const el = findPlotlyGraphDiv(root);\n if (el)\n finish(el);\n });\n const timer = window.setTimeout(() => finish(null), timeoutMs);\n observer.observe(root, { childList: true, subtree: true });\n });\n}\nfunction bindPlotlyEvents(plotEl, model) {\n var _a;\n const requested = requestedEvents.get(model);\n if (!requested || !hasMethod(plotEl, \"on\"))\n return;\n const bound = (_a = boundEvents.get(plotEl)) !== null && _a !== void 0 ? _a : new Set();\n boundEvents.set(plotEl, bound);\n let boundSelected = false;\n for (const event of requested) {\n if (bound.has(event))\n continue;\n bound.add(event);\n if (event === \"plotly_selected\")\n boundSelected = true;\n plotEl.on(event, (data) => {\n model.send(Object.assign({ shinywidgets: \"event\", event }, eventPayload(event, data)), {});\n });\n }\n // A selection is state on the graph div (trace `selectedpoints`), not just\n // an event: a lasso drawn before this bind (e.g. before an accessor's first\n // read asked for the event) is still recoverable, so report the current\n // selection as if plotly.js had just fired it. An empty payload is a\n // no-data `plotly_selected`, which plotly.js itself sends for an empty\n // selection and Python reads as \"nothing selected\".\n if (boundSelected) {\n model.send(Object.assign({ shinywidgets: \"event\", event: \"plotly_selected\" }, selectionSeed(plotEl)), {});\n }\n}\n// Rebuilds a plotly_selected payload from graph-div state: one entry per\n// selected point, plus the region (box range or lasso vertices) plotly.js\n// keeps on the last layout selection.\nfunction selectionSeed(plotEl) {\n const data = plotEl.data;\n if (!Array.isArray(data))\n return {};\n const points = data.flatMap((trace, curveNumber) => {\n // jsonable(): a selection preconfigured from plotly.py arrives as a\n // typed array, which Array.isArray rejects (and whose .map can't hold\n // point objects)\n const selected = jsonable(trace === null || trace === void 0 ? void 0 : trace.selectedpoints);\n if (!Array.isArray(selected))\n return [];\n return selected.map((i) => {\n var _a;\n const entry = {\n trace_index: curveNumber,\n point_index: i,\n };\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n let value = (_a = trace[key]) === null || _a === void 0 ? void 0 : _a[i];\n // A multicategory axis stores one array per category level (so the\n // trace array's length is the level count, not the point count, and\n // indexing it by sample is wrong); the coordinate combines each\n // level's value at i\n if (key !== \"customdata\" && Array.isArray(trace[key]) && Array.isArray(trace[key][0])) {\n value = trace[key].map((level) => level === null || level === void 0 ? void 0 : level[i]);\n }\n else if (value === undefined && key !== \"customdata\") {\n // Coordinates the trace doesn't carry explicitly (implicit x,\n // bar x0/dx) are resolved on calcdata, matching the live event's\n // point objects\n value = calcCoord(plotEl, curveNumber, i, key, trace === null || trace === void 0 ? void 0 : trace.type);\n }\n if (value !== undefined)\n entry[key] = jsonable(value);\n }\n return entry;\n });\n });\n // An empty drag selection is still a selection: plotly.js reports it as\n // points: [] plus the region. So is a preconfigured selectedpoints: []\n // (plotly dims every point with unselected styling). No points, no\n // region, and no trace supplying a selection array means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n const anySelectionArray = data.some((trace) => Array.isArray(jsonable(trace === null || trace === void 0 ? void 0 : trace.selectedpoints)));\n if (points.length === 0 && Object.keys(region).length === 0 && !anySelectionArray) {\n return {};\n }\n return Object.assign({ points }, region);\n}\nfunction regionSeed(plotEl) {\n var _a;\n const selections = (_a = plotEl._fullLayout) === null || _a === void 0 ? void 0 : _a.selections;\n if (!Array.isArray(selections) || selections.length === 0)\n return {};\n const last = selections[selections.length - 1];\n // Selections on later subplots reference their axes (x2, y2, ...), and\n // live events key the region the same way\n const xref = typeof last.xref === \"string\" ? last.xref : \"x\";\n const yref = typeof last.yref === \"string\" ? last.yref : \"y\";\n // A box selection keeps its corners; the event reports each axis ascending\n if (last.x0 !== undefined) {\n const asc = (a, b) => (a <= b ? [a, b] : [b, a]);\n return { range: { [xref]: asc(last.x0, last.x1), [yref]: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\". On date\n // axes the values are date strings with \"_\" for the date/time separator;\n // live lassoPoints restore the space.\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]([^MLZz]+)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n const sep = m[1].lastIndexOf(\",\");\n if (sep < 0)\n continue;\n x.push(lassoValue(m[1].slice(0, sep)));\n y.push(lassoValue(m[1].slice(sep + 1)));\n }\n if (x.length > 0)\n return { lassoPoints: { [xref]: x, [yref]: y } };\n }\n return {};\n}\n// Resolved coordinates from calcdata, for the trace types where its layout\n// is verified: scatter and bar align one calc point per data index\n// (implicit x, x0/dx); scattergl keeps one calc entry whose .t holds typed\n// coordinate arrays. Others don't align by sample index (histograms bin\n// them) or are unverified, so their missing keys stay missing, as in live\n// payloads.\nfunction calcCoord(plotEl, curveNumber, i, key, type) {\n var _a, _b, _c, _d, _e;\n const cd = (_a = plotEl.calcdata) === null || _a === void 0 ? void 0 : _a[curveNumber];\n if (!Array.isArray(cd))\n return undefined;\n if (type === \"scattergl\")\n return (_d = (_c = (_b = cd[0]) === null || _b === void 0 ? void 0 : _b.t) === null || _c === void 0 ? void 0 : _c[key]) === null || _d === void 0 ? void 0 : _d[i];\n if (type === \"scatter\" || type === \"bar\")\n return (_e = cd[i]) === null || _e === void 0 ? void 0 : _e[key];\n return undefined;\n}\nfunction lassoValue(token) {\n const t = token.trim();\n const n = Number(t);\n return Number.isNaN(n) ? t.replace(\"_\", \" \") : n;\n}\n// Shaped like R plotly's event_data(): one entry per point, with its trace\n// index, its index within the trace, coordinates, and customdata.\nfunction eventPayload(event, data) {\n if (event === \"plotly_relayout\")\n return { relayout: data };\n const out = {};\n if (!data)\n return out;\n if (Array.isArray(data.points))\n out.points = data.points.flatMap(pointEntries);\n if (data.range)\n out.range = data.range;\n if (data.lassoPoints)\n out.lassoPoints = data.lassoPoints;\n return out;\n}\nfunction pointEntries(p) {\n var _a;\n // Aggregated traces (e.g. histograms) report every underlying point\n const aggregated = Array.isArray(p.pointNumbers);\n const indices = aggregated ? p.pointNumbers : [(_a = p.pointNumber) !== null && _a !== void 0 ? _a : p.pointIndex];\n return indices.map((i) => {\n var _a, _b;\n const entry = { trace_index: p.curveNumber, point_index: i };\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n const value = aggregated ? (_b = (_a = p.data) === null || _a === void 0 ? void 0 : _a[key]) === null || _b === void 0 ? void 0 : _b[i] : p[key];\n if (value !== undefined)\n entry[key] = jsonable(value);\n }\n return entry;\n });\n}\n// plotly.js decodes plotly.py's binary arrays into typed arrays, which\n// JSON.stringify would turn into {\"0\": ..., \"1\": ...} objects.\nfunction jsonable(value) {\n return ArrayBuffer.isView(value) ? Array.from(value) : value;\n}\nasync function waitForPlotlyReadyToReveal(plotEl, dispatchResize) {\n var _a;\n const plotly = window.Plotly;\n await waitForPlotlyAfterPlot(plotEl);\n if ((_a = plotly === null || plotly === void 0 ? void 0 : plotly.Plots) === null || _a === void 0 ? void 0 : _a.resize) {\n try {\n const afterResize = waitForPlotlyAfterPlot(plotEl);\n await plotly.Plots.resize(plotEl);\n await afterResize;\n }\n catch (err) {\n console.error(\"Error resizing Plotly widget before reveal:\", err);\n }\n }\n dispatchResize();\n}\nfunction waitForPlotlyAfterPlot(plotEl) {\n // If Plotly has already completed its render, _fullLayout will be populated.\n // This avoids waiting for an event that already fired before we subscribed.\n if (plotEl._fullLayout) {\n return Promise.resolve();\n }\n return new Promise((resolve) => {\n const handler = () => {\n cleanup();\n resolve();\n };\n const cleanup = () => {\n if (hasMethod(plotEl, \"removeListener\")) {\n plotEl.removeListener(\"plotly_afterplot\", handler);\n }\n window.clearTimeout(timeoutId);\n };\n const timeoutId = window.setTimeout(() => {\n cleanup();\n resolve();\n }, plotlyAfterPlotTimeoutMs);\n if (hasMethod(plotEl, \"once\")) {\n plotEl.once(\"plotly_afterplot\", handler);\n return;\n }\n if (hasMethod(plotEl, \"on\")) {\n plotEl.on(\"plotly_afterplot\", handler);\n return;\n }\n cleanup();\n resolve();\n });\n}\nfunction hasMethod(x, key) {\n return !!x && typeof x[key] === \"function\";\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/plotly.ts?"); /***/ }), diff --git a/tests/playwright/plotly_selection_seed/app.py b/tests/playwright/plotly_selection_seed/app.py new file mode 100644 index 00000000..ba179a5a --- /dev/null +++ b/tests/playwright/plotly_selection_seed/app.py @@ -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) diff --git a/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py b/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py new file mode 100644 index 00000000..957964e9 --- /dev/null +++ b/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py @@ -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) diff --git a/tests/unit/test_plotly_accessors.py b/tests/unit/test_plotly_accessors.py index c820c69d..5da8a583 100644 --- a/tests/unit/test_plotly_accessors.py +++ b/tests/unit/test_plotly_accessors.py @@ -221,6 +221,46 @@ def test_render_plotly_brushed_and_relayout() -> None: assert r.relayout() == {"xaxis.autorange": True} +def test_selected_and_brushed_are_state_attached_at_render() -> None: + # A selection is state on the graph div, so these attach at render (and + # the browser seeds the current selection on bind): a lasso drawn before + # the first read is not lost. Clicked/streaming accessors stay events. + r, w = rendered() + assert sp.SELECTED.kind == "state" and sp.BRUSHED.kind == "state" + assert sp.CLICKED.kind == "event" and sp.RELAYOUT.kind == "event" + assert ( + w.sent + == [{"shinywidgets": "bind", "events": ["plotly_selected", "plotly_deselect"]}] + * 2 + ) # selected and brushed + + +def test_selection_reported_before_first_read_survives() -> None: + r, w = rendered() + # The bind's seed (or an event) arrives before anything reads the accessor + w.message(event("plotly_selected", points=CLICK_POINTS)) + selected = r.selected() + assert isinstance(selected, pd.DataFrame) + assert selected["point_index"].tolist() == [2, 0] + + +def test_rerender_keeps_selection_until_the_new_plot_reports() -> None: + r, w = rendered() + w.message(event("plotly_selected", points=CLICK_POINTS)) + assert r.selected() is not None + # State accessors don't reset on re-render ... + w2 = FakeFigureWidget() + r._accessors.on_render(w2) + assert r.selected() is not None + # ... the new plot's seed (no selection: a no-data plotly_selected) clears it + w2.message(event("plotly_selected")) + assert r.selected() is None + assert r.brushed() is None + # And the old widget's handlers are gone + w.message(event("plotly_selected", points=CLICK_POINTS)) + assert r.selected() is None + + def test_selected_rows_requires_a_key_for_non_pandas_frames() -> None: # Their rows have no labels, and positions break once the frame is filtered df = pl.DataFrame({"a": [10, 20, 30]})