@@ -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 # -------------
0 commit comments