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 c122dde0c9..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,22 @@ const filterEventData = (gd, eventData, event) => { * into the event object */ const data = gd.data; - - for (let i = 0; i < eventData.points.length; i++) { - const fullPoint = eventData.points[i]; + // 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]; const pointData = filter(function (o) { return !includes(type(o), ['Object', 'Array']); }, fullPoint); 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..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 @@ -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,124 @@ def rendered_range(): assert rendered_range() == [3, 5] assert dash_dcc.get_logs() == [] + + +def test_grbs013_parcoords_hover(dash_dcc): + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Graph( + id="graph", + clear_on_unhover=True, + 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") + + # 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 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() + dash_dcc.wait_for_text_to_equal("#output", "null") + 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() == []