From d40ebe2ee3ca7760787b495b48b922bb35da58f5 Mon Sep 17 00:00:00 2001 From: monsterlag <64719697+monsterlag@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:56:07 +0000 Subject: [PATCH 1/2] Fix parcoords hover event handling in dcc.Graph --- .../src/fragments/Graph.react.js | 8 +- .../integration/graph/test_graph_basics.py | 131 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index c122dde0c9..eefed667ff 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -73,9 +73,12 @@ const filterEventData = (gd, eventData, event) => { * into the event object */ const data = gd.data; + // Parcoords sends a flat point, whose curveNumber is a line index. + const hasPointsArray = Array.isArray(eventData.points); + const eventPoints = hasPointsArray ? eventData.points : [eventData]; - for (let i = 0; i < eventData.points.length; i++) { - const fullPoint = eventData.points[i]; + for (let i = 0; i < eventPoints.length; i++) { + const fullPoint = eventPoints[i]; const pointData = filter(function (o) { return !includes(type(o), ['Object', 'Array']); }, fullPoint); @@ -86,6 +89,7 @@ const filterEventData = (gd, eventData, event) => { } if ( + hasPointsArray && has('curveNumber', fullPoint) && has('customdata', data[pointData.curveNumber]) ) { diff --git a/components/dash-core-components/tests/integration/graph/test_graph_basics.py b/components/dash-core-components/tests/integration/graph/test_graph_basics.py index f268c1edd5..de5d687d1d 100644 --- a/components/dash-core-components/tests/integration/graph/test_graph_basics.py +++ b/components/dash-core-components/tests/integration/graph/test_graph_basics.py @@ -7,6 +7,7 @@ import plotly.express as px import plotly.graph_objects as go from dash import Dash, Input, Output, dcc, html +from selenium.webdriver.common.action_chains import ActionChains import dash.testing.wait as wait @@ -709,3 +710,133 @@ def rendered_range(): assert rendered_range() == [3, 5] assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("clear_on_unhover", [True, False]) +def test_grbs013_parcoords_hover(dash_dcc, clear_on_unhover): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + clear_on_unhover=clear_on_unhover, + config={"plotGlPixelRatio": 1}, + figure={ + "data": [ + { + "type": "parcoords", + "dimensions": [ + { + "label": label, + "range": [0, 4], + "values": [0.5, 3.5, 2], + } + for label in ["A", "B", "C"] + ], + } + ], + "layout": { + "width": 600, + "height": 400, + "margin": {"l": 80, "r": 80, "t": 60, "b": 60}, + }, + }, + ), + html.Pre(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("graph", "hoverData")) + def show_hover(data): + return json.dumps(data) + + dash_dcc.driver.set_window_size(1000, 800) + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .js-plotly-plot:not(.dash-graph--pending)") + dash_dcc.wait_for_text_to_equal("#output", "null") + canvas = dash_dcc.find_element("#graph .gl-canvas-pick") + + # Line 2 exceeds the trace count and must not be used as a trace index. + ActionChains(dash_dcc.driver).move_to_element_with_offset(canvas, -110, 0).perform() + dash_dcc.wait_for_contains_text("#output", '"points"') + hover_data = json.loads(dash_dcc.find_element("#output").text) + assert hover_data == { + "points": [ + { + "x": 190, + "y": 200, + "clientX": canvas.rect["x"] + 190, + "clientY": canvas.rect["y"] + 200, + "dataIndex": 0, + "curveNumber": 2, + } + ] + } + + ActionChains(dash_dcc.driver).move_to_element_with_offset( + canvas, -110, -40 + ).perform() + if clear_on_unhover: + dash_dcc.wait_for_text_to_equal("#output", "null") + else: + assert json.loads(dash_dcc.find_element("#output").text) == hover_data + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("trace_type", ["scatter", "sankey"]) +def test_grbs014_graph_hover_points(dash_dcc, trace_type): + app = Dash(__name__) + if trace_type == "scatter": + trace = { + "type": "scatter", + "x": [1, 2, 3], + "y": [1, 2, 3], + "mode": "markers", + "customdata": ["a", "b", "c"], + } + selector = ".scatterlayer .point" + expected = { + "curveNumber": 0, + "pointNumber": 0, + "x": 1, + "y": 1, + "customdata": "a", + } + else: + trace = { + "type": "sankey", + "node": {"label": ["A", "B"]}, + "link": {"source": [0], "target": [1], "value": [2]}, + } + selector = ".sankey-node" + expected = {"curveNumber": 0, "pointNumber": 0, "label": "A", "value": 2} + + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + figure={"data": [trace]}, + style={"width": 600, "height": 400}, + clear_on_unhover=True, + ), + html.Pre(id="output"), + ] + ) + + @app.callback(Output("output", "children"), Input("graph", "hoverData")) + def show_hover(data): + return json.dumps(data) + + dash_dcc.driver.set_window_size(1000, 800) + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#graph .js-plotly-plot:not(.dash-graph--pending)") + ActionChains(dash_dcc.driver).move_to_element( + dash_dcc.find_element(f"#graph {selector}") + ).perform() + dash_dcc.wait_for_contains_text("#output", '"points"') + hover_data = json.loads(dash_dcc.find_element("#output").text) + assert len(hover_data["points"]) == 1 + point = hover_data["points"][0] + assert {key: point[key] for key in expected} == expected + assert not {"data", "fullData", "xaxis", "yaxis"}.intersection(point) + assert dash_dcc.get_logs() == [] From 9d1c39151985a7a1a3557260f7a00263d87572d0 Mon Sep 17 00:00:00 2001 From: monsterlag <64719697+monsterlag@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:05:37 +0000 Subject: [PATCH 2/2] Give parcoords hoverData the same shape as other traces Reshape the flat parcoords event so curveNumber is the trace index (plotly.js dataIndex) and pointNumber is the hovered line, which also lets the customdata lookup drop its guard. Check only index fields and key presence in the test, drop the clear_on_unhover=False case that could not prove unhover ran, and add the CHANGELOG entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wm7kczaxb2ShcgbbmUx6fa --- CHANGELOG.md | 1 + .../src/fragments/Graph.react.js | 17 +++++++--- .../integration/graph/test_graph_basics.py | 31 +++++++------------ 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 072445db8a..e1e4071ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior. ### Fixed +- [#4077](https://github.com/plotly/dash/pull/4077) Hovering a `parcoords` trace no longer throws in `dcc.Graph`, and `hoverData` now has the same shape as other traces: a `points` list where `curveNumber` is the trace index and `pointNumber` is the hovered line. Fixes [#3128](https://github.com/plotly/dash/issues/3128). - [#4075](https://github.com/plotly/dash/pull/4075) `fastapi` backend: run `before_request` hooks on every route of the Dash app (assets, hook routes, `api_endpoint`, pages, when mounted) and send a non-None hook result as the response, as on Flask. `fastapi` and `quart` also run them on the WebSocket handshake. - [#4075](https://github.com/plotly/dash/pull/4075) Background callbacks without `cache_by` no longer share a result between requests with equal inputs (unread Diskcache results now expire after a day, or after `expire` when set, even with `cache_by`), and callbacks with the same source no longer share a cache key. `build_cache_key` takes a new `callback_key` argument. - [#4075](https://github.com/plotly/dash/pull/4075) `dcc.Location(refresh=True).href`, `html.Area.href` and `html.Base.href` now block `javascript:` URLs like `html.A`. diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index eefed667ff..fddaf4aa44 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -73,9 +73,19 @@ const filterEventData = (gd, eventData, event) => { * into the event object */ const data = gd.data; - // Parcoords sends a flat point, whose curveNumber is a line index. - const hasPointsArray = Array.isArray(eventData.points); - const eventPoints = hasPointsArray ? eventData.points : [eventData]; + // Parcoords sends one flat point instead of a points array. Its + // curveNumber is the line index and dataIndex is the trace index, so + // reshape it to match other traces: curveNumber is the trace and + // pointNumber is the line. + const eventPoints = Array.isArray(eventData.points) + ? eventData.points + : [ + { + ...eventData, + curveNumber: eventData.dataIndex, + pointNumber: eventData.curveNumber, + }, + ]; for (let i = 0; i < eventPoints.length; i++) { const fullPoint = eventPoints[i]; @@ -89,7 +99,6 @@ const filterEventData = (gd, eventData, event) => { } if ( - hasPointsArray && has('curveNumber', fullPoint) && has('customdata', data[pointData.curveNumber]) ) { diff --git a/components/dash-core-components/tests/integration/graph/test_graph_basics.py b/components/dash-core-components/tests/integration/graph/test_graph_basics.py index de5d687d1d..c69b7cbd35 100644 --- a/components/dash-core-components/tests/integration/graph/test_graph_basics.py +++ b/components/dash-core-components/tests/integration/graph/test_graph_basics.py @@ -712,14 +712,13 @@ def rendered_range(): assert dash_dcc.get_logs() == [] -@pytest.mark.parametrize("clear_on_unhover", [True, False]) -def test_grbs013_parcoords_hover(dash_dcc, clear_on_unhover): +def test_grbs013_parcoords_hover(dash_dcc): app = Dash(__name__) app.layout = html.Div( [ dcc.Graph( id="graph", - clear_on_unhover=clear_on_unhover, + clear_on_unhover=True, config={"plotGlPixelRatio": 1}, figure={ "data": [ @@ -756,30 +755,22 @@ def show_hover(data): dash_dcc.wait_for_text_to_equal("#output", "null") canvas = dash_dcc.find_element("#graph .gl-canvas-pick") - # Line 2 exceeds the trace count and must not be used as a trace index. + # Hover line 2 of the only trace: curveNumber is the trace index and + # pointNumber the line index, as for other trace types. ActionChains(dash_dcc.driver).move_to_element_with_offset(canvas, -110, 0).perform() dash_dcc.wait_for_contains_text("#output", '"points"') hover_data = json.loads(dash_dcc.find_element("#output").text) - assert hover_data == { - "points": [ - { - "x": 190, - "y": 200, - "clientX": canvas.rect["x"] + 190, - "clientY": canvas.rect["y"] + 200, - "dataIndex": 0, - "curveNumber": 2, - } - ] - } + assert len(hover_data["points"]) == 1 + point = hover_data["points"][0] + assert point["curveNumber"] == 0 + assert point["pointNumber"] == 2 + # Coordinates depend on layout and pixel ratio, so only check they are sent. + assert {"x", "y", "clientX", "clientY"} <= set(point) ActionChains(dash_dcc.driver).move_to_element_with_offset( canvas, -110, -40 ).perform() - if clear_on_unhover: - dash_dcc.wait_for_text_to_equal("#output", "null") - else: - assert json.loads(dash_dcc.find_element("#output").text) == hover_data + dash_dcc.wait_for_text_to_equal("#output", "null") assert dash_dcc.get_logs() == []