Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -3303,7 +3414,7 @@ def to_dict(self):
"""
# Handle data
# -----------
data = deepcopy(self._data)
data = BaseFigure._fix_segmented_hover(deepcopy(self._data))

# Handle layout
# -------------
Expand Down
34 changes: 34 additions & 0 deletions tests/test_io/test_to_from_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]