Skip to content

Commit 233c549

Browse files
fix #5459: resolve duplicate hover entries in segmented line charts
When adjacent scatter traces share an endpoint, 'x unified' hover shows two entries at the same x value. Fix by splitting each chain trace into a visual-only drawing (hoverinfo='skip') and one single-point companion per data point. Each trace owns its points up to but not including the shared last endpoint, which is instead covered by the following trace as its x[0], ensuring the companion carries the correct customdata
1 parent d28f18e commit 233c549

File tree

2 files changed

+146
-1
lines changed

2 files changed

+146
-1
lines changed

plotly/basedatatypes.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3288,6 +3288,117 @@ def _perform_batch_animate(self, animation_opts):
32883288
if relayout_changes:
32893289
self._dispatch_layout_change_callbacks(relayout_changes)
32903290

3291+
@staticmethod
3292+
def _traces_share_endpoint(trace_curr, trace_next):
3293+
if trace_curr.get("type", "scatter") not in {"scatter", "scattergl"}:
3294+
return False
3295+
if trace_next.get("type", "scatter") not in {"scatter", "scattergl"}:
3296+
return False
3297+
x_curr = trace_curr.get("x")
3298+
x_next = trace_next.get("x")
3299+
# x may be None, a base64-encoded dict, or a non-sequence type
3300+
if x_curr is None or x_next is None:
3301+
return False
3302+
if isinstance(x_curr, dict) or isinstance(x_next, dict):
3303+
return False
3304+
try:
3305+
return (
3306+
len(x_curr) >= 2
3307+
and len(x_next) >= 1
3308+
and str(x_curr[-1]) == str(x_next[0])
3309+
)
3310+
except (KeyError, TypeError, IndexError):
3311+
return False
3312+
3313+
@staticmethod
3314+
def _build_hover_companion(trace, x, y, customdata_index):
3315+
companion = {
3316+
**{key: trace[key] for key in ("name", "hovertemplate", "line") if key in trace},
3317+
"type": trace.get("type", "scatter"),
3318+
"mode": trace.get("mode", "lines"),
3319+
"showlegend": False,
3320+
"x": [x],
3321+
"y": [y],
3322+
}
3323+
customdata = trace.get("customdata")
3324+
if customdata is not None and hasattr(customdata, "__getitem__"):
3325+
try:
3326+
companion["customdata"] = [customdata[customdata_index]]
3327+
except (IndexError, KeyError):
3328+
pass
3329+
return companion
3330+
3331+
@staticmethod
3332+
def _fix_segmented_hover(data):
3333+
"""
3334+
Resolve duplicate hover entries produced by segmented line charts.
3335+
3336+
When adjacent scatter traces share an endpoint (last x of trace[i]
3337+
equals first x of trace[i+1]), "x unified" hover shows two entries
3338+
at that x. For every such chain, each drawing trace is replaced by:
3339+
3340+
* a visual-only copy with ``hoverinfo="skip"`` (keeps the line), and
3341+
* one single-point companion per data point carrying the hover data.
3342+
3343+
A one-point ``"lines"`` trace is invisible (plotly.js needs ≥2 points
3344+
to render a line) but still participates in unified hover with the
3345+
original mode and line style, so the tooltip appearance is unchanged.
3346+
Each trace covers its points up to but not including its last (shared)
3347+
endpoint, which is instead covered by the following trace as its own
3348+
``x[0]``. The last trace in the chain covers all its points.
3349+
3350+
Parameters
3351+
----------
3352+
data : list of dict
3353+
Trace property dicts (already deep-copied from ``self._data``).
3354+
3355+
Returns
3356+
-------
3357+
list of dict
3358+
Possibly expanded list with companion hover traces inserted.
3359+
"""
3360+
# Detect adjacent scatter traces sharing an endpoint
3361+
# --------------------------------------------------
3362+
num_traces = len(data)
3363+
in_chain = [False] * num_traces
3364+
for idx, (trace_curr, trace_next) in enumerate(zip(data, data[1:])):
3365+
if BaseFigure._traces_share_endpoint(trace_curr, trace_next):
3366+
in_chain[idx] = in_chain[idx + 1] = True
3367+
3368+
if not any(in_chain):
3369+
return data
3370+
3371+
# Build expanded trace list with hover companions
3372+
# -----------------------------------------------
3373+
expanded_data = []
3374+
for is_chained, group in itertools.groupby(zip(in_chain, data), key=lambda pair: pair[0]):
3375+
traces = [trace for _, trace in group]
3376+
if not is_chained:
3377+
expanded_data.extend(traces)
3378+
continue
3379+
3380+
for chain_idx, trace in enumerate(traces):
3381+
# Visual-only trace: keeps the line, hidden from hover
3382+
drawing = {**trace, "hoverinfo": "skip"}
3383+
drawing.pop("hovertemplate", None)
3384+
drawing.pop("hovertext", None)
3385+
expanded_data.append(drawing)
3386+
3387+
# One single-point companion per data point.
3388+
y = trace.get("y")
3389+
if y is None:
3390+
continue
3391+
is_last = chain_idx == len(traces) - 1
3392+
end = len(trace["x"]) if is_last else len(trace["x"]) - 1
3393+
for pt_idx in range(end):
3394+
expanded_data.append(
3395+
BaseFigure._build_hover_companion(
3396+
trace, trace["x"][pt_idx], y[pt_idx], pt_idx
3397+
)
3398+
)
3399+
3400+
return expanded_data
3401+
32913402
# Exports
32923403
# -------
32933404
def to_dict(self):
@@ -3303,7 +3414,7 @@ def to_dict(self):
33033414
"""
33043415
# Handle data
33053416
# -----------
3306-
data = deepcopy(self._data)
3417+
data = BaseFigure._fix_segmented_hover(deepcopy(self._data))
33073418

33083419
# Handle layout
33093420
# -------------

tests/test_io/test_to_from_json.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,37 @@ def test_to_dict_empty_np_array_int64():
271271
)
272272
# to_dict() should not raise an exception
273273
fig.to_dict()
274+
275+
276+
def test_to_dict_segmented_hover_non_sharing_traces_unchanged():
277+
fig = go.Figure(
278+
[go.Scatter(x=[1, 5], y=[10, 50]), go.Scatter(x=[6, 10], y=[60, 100])]
279+
)
280+
assert len(fig.to_dict()["data"]) == 2
281+
282+
283+
def test_to_dict_segmented_hover_chain_expansion():
284+
# drawing_A + comp[x=1] + drawing_B + comp[x=5] + comp[x=10] = 5
285+
fig = go.Figure(
286+
[go.Scatter(x=[1, 5], y=[10, 50]), go.Scatter(x=[5, 10], y=[50, 100])]
287+
)
288+
data = fig.to_dict()["data"]
289+
drawings = [t for t in data if t.get("hoverinfo") == "skip"]
290+
companions = [t for t in data if t.get("hoverinfo") != "skip"]
291+
assert len(data) == 5
292+
assert all("hovertemplate" not in t for t in drawings)
293+
assert [t["x"][0] for t in companions] == [1, 5, 10]
294+
assert all(len(t["x"]) == 1 and t["showlegend"] is False for t in companions)
295+
296+
297+
def test_to_dict_segmented_hover_shared_endpoint_uses_next_trace_customdata():
298+
# Companion for x=5 must carry B's customdata[0] ("cd_b0"), not A's ("cd_a1").
299+
fig = go.Figure(
300+
[
301+
go.Scatter(x=[1, 5], y=[10, 50], customdata=["cd_a0", "cd_a1"]),
302+
go.Scatter(x=[5, 10], y=[50, 100], customdata=["cd_b0", "cd_b1"]),
303+
]
304+
)
305+
companions = [t for t in fig.to_dict()["data"] if t.get("hoverinfo") != "skip"]
306+
comp_x5 = companions[1]
307+
assert comp_x5["x"] == [5] and comp_x5["customdata"] == ["cd_b0"]

0 commit comments

Comments
 (0)