From 805f1ae960fce316971be6d9f6c02f515d1c2711 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 2 Nov 2026 16:56:47 -0600 Subject: [PATCH 1/9] Seed plotly selection state on bridge bind; make selected()/brushed() state accessors A plotly selection is state on the graph div (trace selectedpoints, plus the region on the last layout selection), so when the event bridge binds plotly_selected on a graph div it now also reports the current selection as a synthetic plotly_selected message (a no-data one when nothing is selected, the same message plotly.js sends for an empty selection). That lets selected()/brushed() (and so selected_rows()) become state accessors: they attach at render, and a box/lasso selection made before the accessor's first read -- or before the bind request reaches the browser -- is reported instead of silently lost. clicked() stays an event. State accessors don't reset on re-render, so selected()/brushed() now keep their value until the new plot reports its own (initially empty) selection; documented in the render_plotly docstring. --- CHANGELOG.md | 3 +- js/src/plotly.ts | 62 +++++++++++++++++++++++++++++ shinywidgets/_plotly.py | 11 ++++- shinywidgets/_render_widget.py | 6 ++- shinywidgets/static/output.js | 2 +- tests/unit/test_plotly_accessors.py | 40 +++++++++++++++++++ 6 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 576988fe..5126adfe 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..5ab7ef92 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -93,13 +93,75 @@ 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) => { + if (!Array.isArray(trace?.selectedpoints)) return []; + return (trace.selectedpoints as number[]).map((i) => { + const entry: Record = { + trace_index: curveNumber, + point_index: i, + }; + for (const key of ["x", "y", "z", "customdata"]) { + const value = trace[key]?.[i]; + if (value !== undefined) entry[key] = jsonable(value); + } + return entry; + }); + }); + if (points.length === 0) return {}; + return { points, ...regionSeed(plotEl) }; +} + +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]; + // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } }; + } + // A lasso keeps its vertices as an SVG path: "M x,y L x,y ..." + if (typeof last.path === "string") { + const x: number[] = []; + const y: number[] = []; + const vertex = /[ML]\s*(-?[\d.eE+]+)\s*,\s*(-?[\d.eE+]+)/g; + let m: RegExpExecArray | null; + while ((m = vertex.exec(last.path)) !== null) { + x.push(parseFloat(m[1])); + y.push(parseFloat(m[2])); + } + if (x.length > 0) return { lassoPoints: { x, y } }; + } + return {}; } // 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 d06b41c4..644845da 100644 --- a/shinywidgets/_render_widget.py +++ b/shinywidgets/_render_widget.py @@ -3433,7 +3433,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..a5a5cba0 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 if (!Array.isArray(trace === null || trace === void 0 ? void 0 : trace.selectedpoints))\n return [];\n return trace.selectedpoints.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 const value = (_a = trace[key]) === null || _a === void 0 ? void 0 : _a[i];\n if (value !== undefined)\n entry[key] = jsonable(value);\n }\n return entry;\n });\n });\n if (points.length === 0)\n return {};\n return Object.assign({ points }, regionSeed(plotEl));\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.eE+]+)\\s*,\\s*(-?[\\d.eE+]+)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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/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]}) From 4fffd7d3c7672c17d00d0a6951566216054082d7 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 2 Nov 2026 17:37:31 -0600 Subject: [PATCH 2/9] Playwright regression test: selection before first read of plotly state accessors Covers the selection-seed scenario end to end: a box selection made before anything reads selected()/brushed() (gated behind an action button), then a gated first read -- which must report the pre-read selection -- and a deselect (double-click on the plot background) after the read, which must clear the accessors to None. plotly.js emits plotly_selected a few hundred ms after mouseup, so a button click right after the drag can overtake the selection message; the fixture follows the sentinel pattern (an ungated text output reading the same accessor state, like ipyleaflet_eager_attach's synced_zoom) so the test waits for the browser-to-server sync before triggering the gated read. --- tests/playwright/plotly_selection_seed/app.py | 63 +++++++++ .../test_plotly_selection_seed.py | 128 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/playwright/plotly_selection_seed/app.py create mode 100644 tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py diff --git a/tests/playwright/plotly_selection_seed/app.py b/tests/playwright/plotly_selection_seed/app.py new file mode 100644 index 00000000..8a83eb89 --- /dev/null +++ b/tests/playwright/plotly_selection_seed/app.py @@ -0,0 +1,63 @@ +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, +# so nothing consumer-facing reads them at startup. A selection made before +# the button click exercises the eager state attach and the seed on bridge +# bind (see the tests and REPORT.md). + +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.output_text("synced_selection"), + 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)) + + # Sentinel: an ungated read of the same accessor state, so a test can + # wait until a gesture has synced from browser to server before + # triggering a gated read. plotly.js emits plotly_selected a few hundred + # ms after mouseup, so without this a button click right after the drag + # can overtake the selection message. + @render.text + def synced_selection(): + pts = plot.selected() + return "None" if pts is None else str(len(pts)) + + +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..82441257 --- /dev/null +++ b/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py @@ -0,0 +1,128 @@ +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 + ); +}""" + + +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 consumer-facing reads the accessors until the button is + # clicked; the sentinel starts at no selection + expect(page.locator("#selected")).to_have_text("") + expect(page.locator("#selected_rows")).to_have_text("") + expect(page.locator("#brushed")).to_have_text("") + expect(page.locator("#synced_selection")).to_have_text("None", timeout=30000) + + +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. + + 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 below is captured + whether or not the bind request has arrived yet -- forwarded live if the + plot is already bound, reconstructed from the graph div's selection state + on bind otherwise. Pre-change (kind="event", lazy attach on first read), + the selection before the first read was silently lost and this read + returned None. + """ + errors, failed_requests = collect_errors(page) + goto_plot(page, local_app) + + # Select BEFORE the gated first read of selected()/selected_rows()/ + # brushed(), then wait for the selection to sync to the server (the + # sentinel reads the same accessor state ungated) before clicking + box_select(page) + expect(page.locator("#synced_selection")).to_have_text( + SELECTED_COUNT, timeout=30000 + ) + + 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) + expect(page.locator("#synced_selection")).to_have_text( + SELECTED_COUNT, timeout=30000 + ) + 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 sync, then re-read: selected()/brushed() are cleared + page.mouse.dblclick(*point(page, 1, 25)) + expect(page.locator("#synced_selection")).to_have_text("None", timeout=30000) + + 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) From 2efc4f1601a11b1187b013bd2c57de1d6c030d83 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 12:11:18 -0600 Subject: [PATCH 3/9] Review fixes: seed implicit coords and empty selections; ungated fixture read - Coordinates the trace doesn't carry explicitly (implicit x, bar x0/dx) fall back to calcdata, matching what live event point objects supply. - An empty drag selection seeds points: [] plus the region, the same message plotly.js sends for one; only no-points-and-no-region remains the no-data case. - The lasso vertex regex accepts signed exponents (1e-7), which it previously failed to match or misparsed. - The fixture's ungated synced_selection read attached the accessor at startup, so the gated reads weren't genuinely late and the test could pass pre-change via live forwarding. Removed; tests now wait on graph-div selection state plus plotly.js's event-emission delay, and would fail pre-change. --- js/src/plotly.ts | 17 +++++-- shinywidgets/static/output.js | 2 +- tests/playwright/plotly_selection_seed/app.py | 18 ++----- .../test_plotly_selection_seed.py | 50 +++++++++++-------- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 5ab7ef92..15876901 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -129,15 +129,24 @@ function selectionSeed(plotEl: HTMLElement): Record { trace_index: curveNumber, point_index: i, }; + // Coordinates the trace doesn't carry explicitly (implicit x, bar + // x0/dx) are still resolved on calcdata, which is where the live + // event's point objects get them + const calc = (plotEl as any).calcdata?.[curveNumber]?.[i]; for (const key of ["x", "y", "z", "customdata"]) { - const value = trace[key]?.[i]; + let value = trace[key]?.[i]; + if (value === undefined && key !== "customdata") value = calc?.[key]; if (value !== undefined) entry[key] = jsonable(value); } return entry; }); }); - if (points.length === 0) return {}; - return { points, ...regionSeed(plotEl) }; + // An empty drag selection is still a selection: plotly.js reports it as + // points: [] plus the region. No points and no region means nothing was + // ever selected (or a deselect), the no-data case. + const region = regionSeed(plotEl); + if (points.length === 0 && Object.keys(region).length === 0) return {}; + return { points, ...region }; } function regionSeed(plotEl: HTMLElement): Record { @@ -153,7 +162,7 @@ function regionSeed(plotEl: HTMLElement): Record { if (typeof last.path === "string") { const x: number[] = []; const y: number[] = []; - const vertex = /[ML]\s*(-?[\d.eE+]+)\s*,\s*(-?[\d.eE+]+)/g; + const vertex = /[ML]\s*(-?[\d.]+(?:[eE][+-]?\d+)?)\s*,\s*(-?[\d.]+(?:[eE][+-]?\d+)?)/g; let m: RegExpExecArray | null; while ((m = vertex.exec(last.path)) !== null) { x.push(parseFloat(m[1])); diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index a5a5cba0..78728224 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 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 if (!Array.isArray(trace === null || trace === void 0 ? void 0 : trace.selectedpoints))\n return [];\n return trace.selectedpoints.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 const value = (_a = trace[key]) === null || _a === void 0 ? void 0 : _a[i];\n if (value !== undefined)\n entry[key] = jsonable(value);\n }\n return entry;\n });\n });\n if (points.length === 0)\n return {};\n return Object.assign({ points }, regionSeed(plotEl));\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.eE+]+)\\s*,\\s*(-?[\\d.eE+]+)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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 if (!Array.isArray(trace === null || trace === void 0 ? void 0 : trace.selectedpoints))\n return [];\n return trace.selectedpoints.map((i) => {\n var _a, _b, _c;\n const entry = {\n trace_index: curveNumber,\n point_index: i,\n };\n // Coordinates the trace doesn't carry explicitly (implicit x, bar\n // x0/dx) are still resolved on calcdata, which is where the live\n // event's point objects get them\n const calc = (_b = (_a = plotEl.calcdata) === null || _a === void 0 ? void 0 : _a[curveNumber]) === null || _b === void 0 ? void 0 : _b[i];\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n let value = (_c = trace[key]) === null || _c === void 0 ? void 0 : _c[i];\n if (value === undefined && key !== \"customdata\")\n value = calc === null || calc === void 0 ? void 0 : calc[key];\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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)\\s*,\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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 index 8a83eb89..ba179a5a 100644 --- a/tests/playwright/plotly_selection_seed/app.py +++ b/tests/playwright/plotly_selection_seed/app.py @@ -6,9 +6,10 @@ from shinywidgets import output_widget, render_plotly # The accessor reads the tests assert on are gated behind an action button, -# so nothing consumer-facing reads them at startup. A selection made before -# the button click exercises the eager state attach and the seed on bridge -# bind (see the tests and REPORT.md). +# 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")}) @@ -17,7 +18,6 @@ ui.output_text("selected"), ui.output_text("selected_rows"), ui.output_text("brushed"), - ui.output_text("synced_selection"), ui.card(output_widget("plot"), height="450px"), ) @@ -49,15 +49,5 @@ def brushed(): b = plot.brushed() return "None" if b is None else ",".join(sorted(b)) - # Sentinel: an ungated read of the same accessor state, so a test can - # wait until a gesture has synced from browser to server before - # triggering a gated read. plotly.js emits plotly_selected a few hundred - # ms after mouseup, so without this a button click right after the drag - # can overtake the selection message. - @render.text - def synced_selection(): - pts = plot.selected() - return "None" if pts is None else str(len(pts)) - 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 index 82441257..957964e9 100644 --- a/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py +++ b/tests/playwright/plotly_selection_seed/test_plotly_selection_seed.py @@ -24,6 +24,19 @@ ); }""" +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]) @@ -61,37 +74,33 @@ def box_select(page: Page, id: str = "plot") -> None: 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 consumer-facing reads the accessors until the button is - # clicked; the sentinel starts at no selection + # 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("") - expect(page.locator("#synced_selection")).to_have_text("None", timeout=30000) 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. + 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 below is captured - whether or not the bind request has arrived yet -- forwarded live if the - plot is already bound, reconstructed from the graph div's selection state - on bind otherwise. Pre-change (kind="event", lazy attach on first read), - the selection before the first read was silently lost and this read - returned None. + 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(), then wait for the selection to sync to the server (the - # sentinel reads the same accessor state ungated) before clicking + # brushed(), and wait for the selection to reach the server before + # clicking (see wait_for_sync) box_select(page) - expect(page.locator("#synced_selection")).to_have_text( - SELECTED_COUNT, timeout=30000 - ) + wait_for_sync(page) page.locator("#read_selection").click() expect(page.locator("#selected")).to_have_text(SELECTED_POINTS, timeout=30000) @@ -108,17 +117,16 @@ def test_deselect_after_read_clears_selection(page: Page, local_app) -> None: goto_plot(page, local_app) box_select(page) - expect(page.locator("#synced_selection")).to_have_text( - SELECTED_COUNT, timeout=30000 - ) + 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 sync, then re-read: selected()/brushed() are cleared + # 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)) - expect(page.locator("#synced_selection")).to_have_text("None", timeout=30000) + 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) From 272572493761102481b47190e39227adc651560d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 12:21:02 -0600 Subject: [PATCH 4/9] Seed typed-array selectedpoints; skip calcdata fallback for histograms - A selection preconfigured from plotly.py arrives as a typed array, which Array.isArray rejected, dropping the seed; normalize with jsonable() first. - Histogram selectedpoints are sample indices but histogram calcdata is bins, so the calcdata fallback seeded bin counts as y; the live event reads only the original arrays for aggregated traces, so skip the fallback for histograms to match. --- js/src/plotly.ts | 15 ++++++++++++--- shinywidgets/static/output.js | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 15876901..a0e5e844 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -123,8 +123,17 @@ function selectionSeed(plotEl: HTMLElement): Record { const data = (plotEl as any).data; if (!Array.isArray(data)) return {}; const points = data.flatMap((trace: any, curveNumber: number) => { - if (!Array.isArray(trace?.selectedpoints)) return []; - return (trace.selectedpoints as number[]).map((i) => { + // 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 []; + // Aggregated traces (histograms) report sample indices, but their + // calcdata holds bins, which don't align -- and the live event reads + // only the original arrays for them, so the fallback would both corrupt + // values (bin counts as y) and diverge from live payloads + const aggregated = trace?.type === "histogram"; + return (selected as number[]).map((i) => { const entry: Record = { trace_index: curveNumber, point_index: i, @@ -132,7 +141,7 @@ function selectionSeed(plotEl: HTMLElement): Record { // Coordinates the trace doesn't carry explicitly (implicit x, bar // x0/dx) are still resolved on calcdata, which is where the live // event's point objects get them - const calc = (plotEl as any).calcdata?.[curveNumber]?.[i]; + const calc = aggregated ? undefined : (plotEl as any).calcdata?.[curveNumber]?.[i]; for (const key of ["x", "y", "z", "customdata"]) { let value = trace[key]?.[i]; if (value === undefined && key !== "customdata") value = calc?.[key]; diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 78728224..5f6ef215 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 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 if (!Array.isArray(trace === null || trace === void 0 ? void 0 : trace.selectedpoints))\n return [];\n return trace.selectedpoints.map((i) => {\n var _a, _b, _c;\n const entry = {\n trace_index: curveNumber,\n point_index: i,\n };\n // Coordinates the trace doesn't carry explicitly (implicit x, bar\n // x0/dx) are still resolved on calcdata, which is where the live\n // event's point objects get them\n const calc = (_b = (_a = plotEl.calcdata) === null || _a === void 0 ? void 0 : _a[curveNumber]) === null || _b === void 0 ? void 0 : _b[i];\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n let value = (_c = trace[key]) === null || _c === void 0 ? void 0 : _c[i];\n if (value === undefined && key !== \"customdata\")\n value = calc === null || calc === void 0 ? void 0 : calc[key];\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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)\\s*,\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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 // Aggregated traces (histograms) report sample indices, but their\n // calcdata holds bins, which don't align -- and the live event reads\n // only the original arrays for them, so the fallback would both corrupt\n // values (bin counts as y) and diverge from live payloads\n const aggregated = (trace === null || trace === void 0 ? void 0 : trace.type) === \"histogram\";\n return selected.map((i) => {\n var _a, _b, _c;\n const entry = {\n trace_index: curveNumber,\n point_index: i,\n };\n // Coordinates the trace doesn't carry explicitly (implicit x, bar\n // x0/dx) are still resolved on calcdata, which is where the live\n // event's point objects get them\n const calc = aggregated ? undefined : (_b = (_a = plotEl.calcdata) === null || _a === void 0 ? void 0 : _a[curveNumber]) === null || _b === void 0 ? void 0 : _b[i];\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n let value = (_c = trace[key]) === null || _c === void 0 ? void 0 : _c[i];\n if (value === undefined && key !== \"customdata\")\n value = calc === null || calc === void 0 ? void 0 : calc[key];\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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)\\s*,\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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?"); /***/ }), From 0e72bac3d5bd46b2b56ca8b343258e2c4d5d1169 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 12:36:26 -0600 Subject: [PATCH 5/9] Seed review round 3: scattergl coords, date-axis lassos, subplot refs - The calcdata coordinate fallback is restricted to verified layouts and moved into calcCoord(): scatter and bar align per data index; scattergl keeps resolved coords in typed arrays on the single calc entry's .t (its top-level x/y are false sentinels); other types are skipped, so unverified layouts omit keys rather than seed wrong values. - Lasso vertices are parsed generically (M/L segments split on the comma), which also restores date-axis values: plotly stores them as date strings with '_' for the date/time separator, live lassoPoints use a space. - The seeded region is keyed by the saved selection's xref/yref (x2/y2 on later subplots), matching live range/lassoPoints keys. Each verified against the compiled bridge: scattergl y-only seeds x=0,1; a date-axis lasso seeds vertices identical to the live event; a second-subplot selection seeds range keyed x2/y2 with the subplot trace's points. --- js/src/plotly.ts | 65 +++++++++++++++++++++++++---------- shinywidgets/static/output.js | 2 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index a0e5e844..98ec0737 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -128,23 +128,19 @@ function selectionSeed(plotEl: HTMLElement): Record { // point objects) const selected = jsonable(trace?.selectedpoints); if (!Array.isArray(selected)) return []; - // Aggregated traces (histograms) report sample indices, but their - // calcdata holds bins, which don't align -- and the live event reads - // only the original arrays for them, so the fallback would both corrupt - // values (bin counts as y) and diverge from live payloads - const aggregated = trace?.type === "histogram"; return (selected as number[]).map((i) => { const entry: Record = { trace_index: curveNumber, point_index: i, }; - // Coordinates the trace doesn't carry explicitly (implicit x, bar - // x0/dx) are still resolved on calcdata, which is where the live - // event's point objects get them - const calc = aggregated ? undefined : (plotEl as any).calcdata?.[curveNumber]?.[i]; for (const key of ["x", "y", "z", "customdata"]) { let value = trace[key]?.[i]; - if (value === undefined && key !== "customdata") value = calc?.[key]; + 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; @@ -162,26 +158,59 @@ 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } }; + 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 ..." + // 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: number[] = []; - const y: number[] = []; - const vertex = /[ML]\s*(-?[\d.]+(?:[eE][+-]?\d+)?)\s*,\s*(-?[\d.]+(?:[eE][+-]?\d+)?)/g; + const x: unknown[] = []; + const y: unknown[] = []; + const vertex = /[ML]([^ML]+)/g; let m: RegExpExecArray | null; while ((m = vertex.exec(last.path)) !== null) { - x.push(parseFloat(m[1])); - y.push(parseFloat(m[2])); + 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: { x, y } }; + 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 n = Number(token); + return Number.isNaN(n) ? token.replace("_", " ") : n; +} + // Shaped like R plotly's event_data(): one entry per point, with its trace // index, its index within the trace, coordinates, and customdata. function eventPayload(event: string, data: any): Record { diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 5f6ef215..e6cd1991 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 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 // Aggregated traces (histograms) report sample indices, but their\n // calcdata holds bins, which don't align -- and the live event reads\n // only the original arrays for them, so the fallback would both corrupt\n // values (bin counts as y) and diverge from live payloads\n const aggregated = (trace === null || trace === void 0 ? void 0 : trace.type) === \"histogram\";\n return selected.map((i) => {\n var _a, _b, _c;\n const entry = {\n trace_index: curveNumber,\n point_index: i,\n };\n // Coordinates the trace doesn't carry explicitly (implicit x, bar\n // x0/dx) are still resolved on calcdata, which is where the live\n // event's point objects get them\n const calc = aggregated ? undefined : (_b = (_a = plotEl.calcdata) === null || _a === void 0 ? void 0 : _a[curveNumber]) === null || _b === void 0 ? void 0 : _b[i];\n for (const key of [\"x\", \"y\", \"z\", \"customdata\"]) {\n let value = (_c = trace[key]) === null || _c === void 0 ? void 0 : _c[i];\n if (value === undefined && key !== \"customdata\")\n value = calc === null || calc === void 0 ? void 0 : calc[key];\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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 // 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: { x: asc(last.x0, last.x1), y: asc(last.y0, last.y1) } };\n }\n // A lasso keeps its vertices as an SVG path: \"M x,y L x,y ...\"\n if (typeof last.path === \"string\") {\n const x = [];\n const y = [];\n const vertex = /[ML]\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)\\s*,\\s*(-?[\\d.]+(?:[eE][+-]?\\d+)?)/g;\n let m;\n while ((m = vertex.exec(last.path)) !== null) {\n x.push(parseFloat(m[1]));\n y.push(parseFloat(m[2]));\n }\n if (x.length > 0)\n return { lassoPoints: { x, y } };\n }\n return {};\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 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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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]([^ML]+)/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 n = Number(token);\n return Number.isNaN(n) ? token.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?"); /***/ }), From 4817e5fa11cc9c54f86f065a51a496f8acaeeb32 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 12:42:46 -0600 Subject: [PATCH 6/9] Stop lasso vertex captures before the closing Z command A preconfigured closed path (M0,0L1,0L1,1Z) captured '1Z' as the final coordinate, which Number() rejects and the date fallback returned as the string '1Z'. --- js/src/plotly.ts | 2 +- shinywidgets/static/output.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 98ec0737..89bb11a0 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -173,7 +173,7 @@ function regionSeed(plotEl: HTMLElement): Record { if (typeof last.path === "string") { const x: unknown[] = []; const y: unknown[] = []; - const vertex = /[ML]([^ML]+)/g; + const vertex = /[ML]([^MLZz]+)/g; let m: RegExpExecArray | null; while ((m = vertex.exec(last.path)) !== null) { const sep = m[1].lastIndexOf(","); diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index e6cd1991..ef9d3b8c 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 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 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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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]([^ML]+)/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 n = Number(token);\n return Number.isNaN(n) ? token.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?"); +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 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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 n = Number(token);\n return Number.isNaN(n) ? token.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?"); /***/ }), From dcd88f0789d4e8418028f0e0e07c39cafe888517 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 12:55:04 -0600 Subject: [PATCH 7/9] Seed review round 5: multicategory coords, trim lasso vertex whitespace - Multicategory axes store one array per category level, so trace[key][i] is a level array, not the coordinate; reconstruct it level-wise (verified: seed x becomes ['A', 'two'], identical to the live event). - Lasso vertex tokens are trimmed before conversion, so SVG formatting whitespace doesn't survive into date-valued region coordinates. --- js/src/plotly.ts | 12 +++++++++--- shinywidgets/static/output.js | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 89bb11a0..9100fd0a 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -135,7 +135,12 @@ function selectionSeed(plotEl: HTMLElement): Record { }; for (const key of ["x", "y", "z", "customdata"]) { let value = trace[key]?.[i]; - if (value === undefined && key !== "customdata") { + // A multicategory axis stores one array per category level, so + // trace[key][i] is the wrong level's whole array; the coordinate + // combines each level's value at i + if (key !== "customdata" && Array.isArray(value) && 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 @@ -207,8 +212,9 @@ function calcCoord( } function lassoValue(token: string): unknown { - const n = Number(token); - return Number.isNaN(n) ? token.replace("_", " ") : n; + 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/static/output.js b/shinywidgets/static/output.js index ef9d3b8c..afd46144 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 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 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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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 n = Number(token);\n return Number.isNaN(n) ? token.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?"); +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, _b;\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\n // trace[key][i] is the wrong level's whole array; the coordinate\n // combines each level's value at i\n if (key !== \"customdata\" && Array.isArray(value) && Array.isArray((_b = trace[key]) === null || _b === void 0 ? void 0 : _b[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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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?"); /***/ }), From aae98bc561501dfbd10116fbebea5f0cfc493a7c Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 13:01:32 -0600 Subject: [PATCH 8/9] Detect multicategory axes structurally, not per selected point A multicategory trace array's length is the category level count, so for a selected index beyond it trace[key][i] is undefined and the per-point check fell through to the wrong path; detect array-of-arrays on the trace attribute itself (verified: index 2 seeds ['B', 'one']). --- js/src/plotly.ts | 9 +++++---- shinywidgets/static/output.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index 9100fd0a..fe988a2c 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -135,10 +135,11 @@ function selectionSeed(plotEl: HTMLElement): Record { }; for (const key of ["x", "y", "z", "customdata"]) { let value = trace[key]?.[i]; - // A multicategory axis stores one array per category level, so - // trace[key][i] is the wrong level's whole array; the coordinate - // combines each level's value at i - if (key !== "customdata" && Array.isArray(value) && Array.isArray(trace[key]?.[0])) { + // 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, diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index afd46144..7335a4df 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 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, _b;\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\n // trace[key][i] is the wrong level's whole array; the coordinate\n // combines each level's value at i\n if (key !== \"customdata\" && Array.isArray(value) && Array.isArray((_b = trace[key]) === null || _b === void 0 ? void 0 : _b[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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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?"); +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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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?"); /***/ }), From 7e95d60596ac4e7fb94327b19234a65e6be8f789 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 4 Nov 2026 13:09:10 -0600 Subject: [PATCH 9/9] Seed a preconfigured empty selection as points: [] plotly treats selectedpoints: [] as an active empty selection (every point gets unselected styling), distinct from an absent selectedpoints; the seed now emits points: [] for it (empty DataFrame, matching the empty-drag case) while a plot with no selection arrays anywhere still seeds the no-data message (None). --- js/src/plotly.ts | 11 +++++++++-- shinywidgets/static/output.js | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/js/src/plotly.ts b/js/src/plotly.ts index fe988a2c..8876f7bf 100644 --- a/js/src/plotly.ts +++ b/js/src/plotly.ts @@ -153,10 +153,17 @@ function selectionSeed(plotEl: HTMLElement): Record { }); }); // An empty drag selection is still a selection: plotly.js reports it as - // points: [] plus the region. No points and no region means nothing was + // 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); - if (points.length === 0 && Object.keys(region).length === 0) return {}; + 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 }; } diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 7335a4df..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 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. No points and no region means nothing was\n // ever selected (or a deselect), the no-data case.\n const region = regionSeed(plotEl);\n if (points.length === 0 && Object.keys(region).length === 0)\n return {};\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?"); +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?"); /***/ }),