diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d54..65c8701d77 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -3288,6 +3288,117 @@ def _perform_batch_animate(self, animation_opts): if relayout_changes: self._dispatch_layout_change_callbacks(relayout_changes) + @staticmethod + def _traces_share_endpoint(trace_curr, trace_next): + if trace_curr.get("type", "scatter") not in {"scatter", "scattergl"}: + return False + if trace_next.get("type", "scatter") not in {"scatter", "scattergl"}: + return False + x_curr = trace_curr.get("x") + x_next = trace_next.get("x") + # x may be None, a base64-encoded dict, or a non-sequence type + if x_curr is None or x_next is None: + return False + if isinstance(x_curr, dict) or isinstance(x_next, dict): + return False + try: + return ( + len(x_curr) >= 2 + and len(x_next) >= 1 + and str(x_curr[-1]) == str(x_next[0]) + ) + except (KeyError, TypeError, IndexError): + return False + + @staticmethod + def _build_hover_companion(trace, x, y, customdata_index): + companion = { + **{key: trace[key] for key in ("name", "hovertemplate", "line") if key in trace}, + "type": trace.get("type", "scatter"), + "mode": trace.get("mode", "lines"), + "showlegend": False, + "x": [x], + "y": [y], + } + customdata = trace.get("customdata") + if customdata is not None and hasattr(customdata, "__getitem__"): + try: + companion["customdata"] = [customdata[customdata_index]] + except (IndexError, KeyError): + pass + return companion + + @staticmethod + def _fix_segmented_hover(data): + """ + Resolve duplicate hover entries produced by segmented line charts. + + When adjacent scatter traces share an endpoint (last x of trace[i] + equals first x of trace[i+1]), "x unified" hover shows two entries + at that x. For every such chain, each drawing trace is replaced by: + + * a visual-only copy with ``hoverinfo="skip"`` (keeps the line), and + * one single-point companion per data point carrying the hover data. + + A one-point ``"lines"`` trace is invisible (plotly.js needs ≥2 points + to render a line) but still participates in unified hover with the + original mode and line style, so the tooltip appearance is unchanged. + Each trace covers its points up to but not including its last (shared) + endpoint, which is instead covered by the following trace as its own + ``x[0]``. The last trace in the chain covers all its points. + + Parameters + ---------- + data : list of dict + Trace property dicts (already deep-copied from ``self._data``). + + Returns + ------- + list of dict + Possibly expanded list with companion hover traces inserted. + """ + # Detect adjacent scatter traces sharing an endpoint + # -------------------------------------------------- + num_traces = len(data) + in_chain = [False] * num_traces + for idx, (trace_curr, trace_next) in enumerate(zip(data, data[1:])): + if BaseFigure._traces_share_endpoint(trace_curr, trace_next): + in_chain[idx] = in_chain[idx + 1] = True + + if not any(in_chain): + return data + + # Build expanded trace list with hover companions + # ----------------------------------------------- + expanded_data = [] + for is_chained, group in itertools.groupby(zip(in_chain, data), key=lambda pair: pair[0]): + traces = [trace for _, trace in group] + if not is_chained: + expanded_data.extend(traces) + continue + + for chain_idx, trace in enumerate(traces): + # Visual-only trace: keeps the line, hidden from hover + drawing = {**trace, "hoverinfo": "skip"} + drawing.pop("hovertemplate", None) + drawing.pop("hovertext", None) + expanded_data.append(drawing) + + # One single-point companion per data point. + y = trace.get("y") + if y is None: + continue + is_last = chain_idx == len(traces) - 1 + end = len(trace["x"]) if is_last else len(trace["x"]) - 1 + for pt_idx in range(end): + expanded_data.append( + BaseFigure._build_hover_companion( + trace, trace["x"][pt_idx], y[pt_idx], pt_idx + ) + ) + + return expanded_data + # Exports # ------- def to_dict(self): @@ -3303,7 +3414,7 @@ def to_dict(self): """ # Handle data # ----------- - data = deepcopy(self._data) + data = BaseFigure._fix_segmented_hover(deepcopy(self._data)) # Handle layout # ------------- diff --git a/tests/test_io/test_to_from_json.py b/tests/test_io/test_to_from_json.py index 21b9473b39..a1ae860e6c 100644 --- a/tests/test_io/test_to_from_json.py +++ b/tests/test_io/test_to_from_json.py @@ -271,3 +271,37 @@ def test_to_dict_empty_np_array_int64(): ) # to_dict() should not raise an exception fig.to_dict() + + +def test_to_dict_segmented_hover_non_sharing_traces_unchanged(): + fig = go.Figure( + [go.Scatter(x=[1, 5], y=[10, 50]), go.Scatter(x=[6, 10], y=[60, 100])] + ) + assert len(fig.to_dict()["data"]) == 2 + + +def test_to_dict_segmented_hover_chain_expansion(): + # drawing_A + comp[x=1] + drawing_B + comp[x=5] + comp[x=10] = 5 + fig = go.Figure( + [go.Scatter(x=[1, 5], y=[10, 50]), go.Scatter(x=[5, 10], y=[50, 100])] + ) + data = fig.to_dict()["data"] + drawings = [t for t in data if t.get("hoverinfo") == "skip"] + companions = [t for t in data if t.get("hoverinfo") != "skip"] + assert len(data) == 5 + assert all("hovertemplate" not in t for t in drawings) + assert [t["x"][0] for t in companions] == [1, 5, 10] + assert all(len(t["x"]) == 1 and t["showlegend"] is False for t in companions) + + +def test_to_dict_segmented_hover_shared_endpoint_uses_next_trace_customdata(): + # Companion for x=5 must carry B's customdata[0] ("cd_b0"), not A's ("cd_a1"). + fig = go.Figure( + [ + go.Scatter(x=[1, 5], y=[10, 50], customdata=["cd_a0", "cd_a1"]), + go.Scatter(x=[5, 10], y=[50, 100], customdata=["cd_b0", "cd_b1"]), + ] + ) + companions = [t for t in fig.to_dict()["data"] if t.get("hoverinfo") != "skip"] + comp_x5 = companions[1] + assert comp_x5["x"] == [5] and comp_x5["customdata"] == ["cd_b0"]