pylocuszoom 1.2.0__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pylocuszoom/__init__.py CHANGED
@@ -40,6 +40,9 @@ __version__ = "1.1.2"
40
40
  # Backend types
41
41
  from .backends import BackendType, get_backend
42
42
 
43
+ # Colocalization visualization
44
+ from .coloc_plotter import ColocPlotter
45
+
43
46
  # Colors and LD
44
47
  from .colors import (
45
48
  LEAD_SNP_COLOR,
@@ -101,10 +104,13 @@ from .forest import validate_forest_df
101
104
  from .gene_track import get_nearest_gene, plot_gene_track
102
105
 
103
106
  # Labels
104
- from .labels import add_snp_labels
107
+ from .labels import add_snp_labels, adjust_snp_labels
105
108
 
106
109
  # LD calculation
107
- from .ld import calculate_ld
110
+ from .ld import calculate_ld, calculate_pairwise_ld
111
+
112
+ # LD heatmap visualization
113
+ from .ld_heatmap_plotter import LDHeatmapPlotter
108
114
 
109
115
  # File format loaders
110
116
  from .loaders import (
@@ -137,6 +143,9 @@ from .logging import disable_logging, enable_logging
137
143
  # Manhattan and QQ plotting
138
144
  from .manhattan_plotter import ManhattanPlotter
139
145
 
146
+ # Miami plot (mirrored Manhattan comparison)
147
+ from .miami_plotter import MiamiPlotter
148
+
140
149
  # PheWAS support
141
150
  from .phewas import validate_phewas_df
142
151
  from .plotter import LocusZoomPlotter
@@ -161,7 +170,10 @@ __all__ = [
161
170
  "__version__",
162
171
  "LocusZoomPlotter",
163
172
  "ManhattanPlotter",
173
+ "MiamiPlotter",
164
174
  "StatsPlotter",
175
+ "LDHeatmapPlotter",
176
+ "ColocPlotter",
165
177
  # Backends
166
178
  "BackendType",
167
179
  "get_backend",
@@ -180,8 +192,10 @@ __all__ = [
180
192
  "plot_gene_track",
181
193
  # LD
182
194
  "calculate_ld",
195
+ "calculate_pairwise_ld",
183
196
  # Labels
184
197
  "add_snp_labels",
198
+ "adjust_snp_labels",
185
199
  # Recombination
186
200
  "add_recombination_overlay",
187
201
  "ensure_recomb_maps",
@@ -3,10 +3,13 @@
3
3
  Defines the interface that matplotlib, plotly, and bokeh backends must implement.
4
4
  """
5
5
 
6
- from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
6
+ from typing import TYPE_CHECKING, Any, Callable, List, Optional, Protocol, Tuple, Union
7
7
 
8
8
  import pandas as pd
9
9
 
10
+ if TYPE_CHECKING:
11
+ pass
12
+
10
13
 
11
14
  class PlotBackend(Protocol):
12
15
  """Protocol defining the backend interface for LocusZoom plots.
@@ -330,7 +333,8 @@ class PlotBackend(Protocol):
330
333
  label_top_n: int,
331
334
  genes_df: Optional[pd.DataFrame],
332
335
  chrom: int,
333
- ) -> None:
336
+ adjust: bool = True,
337
+ ) -> List[Any]:
334
338
  """Add SNP labels to plot.
335
339
 
336
340
  No-op if supports_snp_labels=False. Matplotlib uses adjustText
@@ -345,6 +349,23 @@ class PlotBackend(Protocol):
345
349
  label_top_n: Number of top SNPs to label.
346
350
  genes_df: Gene annotations (unused, for signature compatibility).
347
351
  chrom: Chromosome number (unused, for signature compatibility).
352
+ adjust: If True, run adjustText immediately. If False, caller
353
+ must call adjust_snp_labels() after setting axis limits.
354
+
355
+ Returns:
356
+ List of text annotation objects (empty for non-matplotlib backends).
357
+ """
358
+ ...
359
+
360
+ def adjust_snp_labels(self, ax: Any, texts: List[Any]) -> None:
361
+ """Adjust SNP label positions after axis limits are set.
362
+
363
+ This should be called AFTER all axis limits have been finalized,
364
+ as adjustText needs to know the final plot bounds.
365
+
366
+ Args:
367
+ ax: Axes or panel.
368
+ texts: List of text annotation objects from add_snp_labels().
348
369
  """
349
370
  ...
350
371
 
@@ -686,6 +707,21 @@ class PlotBackend(Protocol):
686
707
  """
687
708
  ...
688
709
 
710
+ def add_effect_legend(
711
+ self,
712
+ ax: Any,
713
+ effect_bins: List[Tuple[float, str, str]],
714
+ ) -> None:
715
+ """Add effect direction legend for colocalization plots.
716
+
717
+ Shows effect direction categories (same direction, opposite, missing).
718
+
719
+ Args:
720
+ ax: Axes or panel.
721
+ effect_bins: List of (threshold, label, color) tuples.
722
+ """
723
+ ...
724
+
689
725
  def add_eqtl_legend(
690
726
  self,
691
727
  ax: Any,
@@ -831,3 +867,59 @@ class PlotBackend(Protocol):
831
867
  fig: Figure object.
832
868
  """
833
869
  ...
870
+
871
+ # =========================================================================
872
+ # Heatmap Rendering (for LD heatmaps)
873
+ # =========================================================================
874
+
875
+ def add_heatmap(
876
+ self,
877
+ ax: Any,
878
+ data: Any,
879
+ x_coords: List[float],
880
+ y_coords: List[float],
881
+ cmap_colors: Optional[List[str]] = None,
882
+ vmin: float = 0.0,
883
+ vmax: float = 1.0,
884
+ mask_upper: bool = True,
885
+ ) -> Any:
886
+ """Render heatmap with optional triangular masking.
887
+
888
+ Used for LD heatmap visualization where data is a symmetric matrix
889
+ and typically only the lower triangle is displayed.
890
+
891
+ Args:
892
+ ax: Axes or panel to plot on.
893
+ data: 2D numpy array of values (use NaN for missing data).
894
+ x_coords: X coordinates for cell positions.
895
+ y_coords: Y coordinates for cell positions.
896
+ cmap_colors: Color gradient endpoints [start_color, end_color].
897
+ Defaults to white-to-red ["#FFFFFF", "#FF0000"].
898
+ vmin: Minimum value for color scale.
899
+ vmax: Maximum value for color scale.
900
+ mask_upper: If True, mask upper triangle for lower-triangular display.
901
+
902
+ Returns:
903
+ Heatmap object (mappable for colorbar attachment).
904
+ """
905
+ ...
906
+
907
+ def add_colorbar(
908
+ self,
909
+ ax: Any,
910
+ mappable: Any,
911
+ label: str = "R²",
912
+ orientation: str = "vertical",
913
+ ) -> Any:
914
+ """Add colorbar legend for heatmap.
915
+
916
+ Args:
917
+ ax: Axes or panel (or figure for some backends).
918
+ mappable: Heatmap object returned by add_heatmap.
919
+ label: Colorbar label (e.g., "R²" or "D'").
920
+ orientation: "vertical" or "horizontal".
921
+
922
+ Returns:
923
+ Colorbar object.
924
+ """
925
+ ...
@@ -623,7 +623,12 @@ class BokehBackend:
623
623
  label_top_n: int,
624
624
  genes_df: Optional[pd.DataFrame],
625
625
  chrom: int,
626
- ) -> None:
626
+ adjust: bool = True,
627
+ ) -> List[Any]:
628
+ """No-op: Bokeh uses hover tooltips instead of text labels."""
629
+ return []
630
+
631
+ def adjust_snp_labels(self, ax: Any, texts: List[Any]) -> None:
627
632
  """No-op: Bokeh uses hover tooltips instead of text labels."""
628
633
  pass
629
634
 
@@ -645,11 +650,16 @@ class BokehBackend:
645
650
  if hasattr(x_range, "start") and x_range.start is not None
646
651
  else 0
647
652
  )
648
- y = (
649
- y_range.start + y_frac * (y_range.end - y_range.start)
650
- if hasattr(y_range, "start") and y_range.start is not None
651
- else 0
652
- )
653
+
654
+ # Handle both normal and inverted y-axis
655
+ # For inverted axis (start > end), y_frac=0.95 should be near start (top visually)
656
+ if hasattr(y_range, "start") and y_range.start is not None:
657
+ y_min = min(y_range.start, y_range.end)
658
+ y_max = max(y_range.start, y_range.end)
659
+ # Position label at top of visible range regardless of axis direction
660
+ y = y_min + y_frac * (y_max - y_min)
661
+ else:
662
+ y = 0
653
663
 
654
664
  label_obj = Label(
655
665
  x=x,
@@ -734,6 +744,18 @@ class BokehBackend:
734
744
  items.append(self._add_legend_item(ax, source, label, color, "square"))
735
745
  self._create_legend(ax, items, "r²")
736
746
 
747
+ def add_effect_legend(
748
+ self,
749
+ ax: figure,
750
+ effect_bins: List[Tuple[float, str, str]],
751
+ ) -> None:
752
+ """Add effect direction legend for colocalization plots."""
753
+ source = self._ensure_legend_range(ax)
754
+ items = []
755
+ for _, label, color in effect_bins:
756
+ items.append(self._add_legend_item(ax, source, label, color, "circle"))
757
+ self._create_legend(ax, items, "Effect")
758
+
737
759
  def add_legend(
738
760
  self,
739
761
  ax: figure,
@@ -968,3 +990,135 @@ class BokehBackend:
968
990
  """
969
991
  # Bokeh handles layout differently - column spacing is fixed
970
992
  pass
993
+
994
+ def add_heatmap(
995
+ self,
996
+ ax: figure,
997
+ data: Any,
998
+ x_coords: List[float],
999
+ y_coords: List[float],
1000
+ cmap_colors: Optional[List[str]] = None,
1001
+ vmin: float = 0.0,
1002
+ vmax: float = 1.0,
1003
+ mask_upper: bool = True,
1004
+ ) -> Any:
1005
+ """Render heatmap with optional triangular masking.
1006
+
1007
+ Args:
1008
+ ax: Bokeh figure.
1009
+ data: 2D numpy array of values (NaN for missing).
1010
+ x_coords: X coordinates for cells.
1011
+ y_coords: Y coordinates for cells.
1012
+ cmap_colors: Color gradient endpoints [start, end].
1013
+ vmin: Minimum value for color scale.
1014
+ vmax: Maximum value for color scale.
1015
+ mask_upper: If True, mask upper triangle.
1016
+
1017
+ Returns:
1018
+ LinearColorMapper for colorbar attachment.
1019
+ """
1020
+ import numpy as np
1021
+ from bokeh.models import LinearColorMapper
1022
+
1023
+ if cmap_colors is None:
1024
+ cmap_colors = ["#FFFFFF", "#FF0000"]
1025
+
1026
+ # Create custom palette from start to end color
1027
+ # For a simple 2-color gradient, create a palette of intermediate colors
1028
+ palette = _create_color_palette(cmap_colors[0], cmap_colors[1], 256)
1029
+
1030
+ mapper = LinearColorMapper(
1031
+ palette=palette,
1032
+ low=vmin,
1033
+ high=vmax,
1034
+ nan_color="#808080", # Grey for missing
1035
+ )
1036
+
1037
+ # Build rect data for lower triangle
1038
+ xs, ys, values = [], [], []
1039
+ n = data.shape[0]
1040
+ for i in range(n):
1041
+ for j in range(n):
1042
+ # Lower triangle including diagonal
1043
+ if mask_upper and j > i:
1044
+ continue
1045
+ val = data[i, j]
1046
+ xs.append(j)
1047
+ ys.append(i)
1048
+ values.append(val if not np.isnan(val) else float("nan"))
1049
+
1050
+ source = ColumnDataSource({"x": xs, "y": ys, "value": values})
1051
+
1052
+ ax.rect(
1053
+ x="x",
1054
+ y="y",
1055
+ width=1,
1056
+ height=1,
1057
+ fill_color={"field": "value", "transform": mapper},
1058
+ line_color=None,
1059
+ source=source,
1060
+ )
1061
+ return mapper
1062
+
1063
+ def add_colorbar(
1064
+ self,
1065
+ ax: figure,
1066
+ mappable: Any,
1067
+ label: str = "R²",
1068
+ orientation: str = "vertical",
1069
+ ) -> Any:
1070
+ """Add colorbar legend for heatmap.
1071
+
1072
+ Args:
1073
+ ax: Bokeh figure.
1074
+ mappable: LinearColorMapper from add_heatmap.
1075
+ label: Colorbar label.
1076
+ orientation: "vertical" or "horizontal".
1077
+
1078
+ Returns:
1079
+ ColorBar object.
1080
+ """
1081
+ from bokeh.models import BasicTicker, ColorBar
1082
+
1083
+ orientation_map = {"vertical": "vertical", "horizontal": "horizontal"}
1084
+ color_bar = ColorBar(
1085
+ color_mapper=mappable,
1086
+ ticker=BasicTicker(),
1087
+ label_standoff=6,
1088
+ title=label,
1089
+ orientation=orientation_map.get(orientation, "vertical"),
1090
+ )
1091
+ ax.add_layout(color_bar, "right")
1092
+ return color_bar
1093
+
1094
+
1095
+ def _create_color_palette(start_color: str, end_color: str, n_colors: int) -> List[str]:
1096
+ """Create a linear color palette between two colors.
1097
+
1098
+ Args:
1099
+ start_color: Starting hex color.
1100
+ end_color: Ending hex color.
1101
+ n_colors: Number of colors in palette.
1102
+
1103
+ Returns:
1104
+ List of hex color strings.
1105
+ """
1106
+
1107
+ def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
1108
+ hex_color = hex_color.lstrip("#")
1109
+ return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
1110
+
1111
+ def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
1112
+ return "#{:02x}{:02x}{:02x}".format(*rgb)
1113
+
1114
+ start_rgb = hex_to_rgb(start_color)
1115
+ end_rgb = hex_to_rgb(end_color)
1116
+
1117
+ palette = []
1118
+ for i in range(n_colors):
1119
+ t = i / (n_colors - 1) if n_colors > 1 else 0
1120
+ r = int(start_rgb[0] + t * (end_rgb[0] - start_rgb[0]))
1121
+ g = int(start_rgb[1] + t * (end_rgb[1] - start_rgb[1]))
1122
+ b = int(start_rgb[2] + t * (end_rgb[2] - start_rgb[2]))
1123
+ palette.append(rgb_to_hex((r, g, b)))
1124
+ return palette
@@ -264,7 +264,8 @@ class MatplotlibBackend:
264
264
  label_top_n: int,
265
265
  genes_df: Optional[pd.DataFrame],
266
266
  chrom: int,
267
- ) -> None:
267
+ adjust: bool = True,
268
+ ) -> List[Any]:
268
269
  """Add SNP labels using adjustText.
269
270
 
270
271
  Args:
@@ -276,10 +277,15 @@ class MatplotlibBackend:
276
277
  label_top_n: Number of top SNPs to label.
277
278
  genes_df: Gene annotations (unused, for signature compatibility).
278
279
  chrom: Chromosome number (unused, for signature compatibility).
280
+ adjust: If True, run adjustText immediately. If False, caller
281
+ must call adjust_snp_labels() after setting axis limits.
282
+
283
+ Returns:
284
+ List of text annotation objects.
279
285
  """
280
286
  from ..labels import add_snp_labels as _add_snp_labels
281
287
 
282
- _add_snp_labels(
288
+ return _add_snp_labels(
283
289
  ax,
284
290
  df,
285
291
  pos_col=pos_col,
@@ -288,8 +294,20 @@ class MatplotlibBackend:
288
294
  label_top_n=label_top_n,
289
295
  genes_df=genes_df,
290
296
  chrom=chrom,
297
+ adjust=adjust,
291
298
  )
292
299
 
300
+ def adjust_snp_labels(self, ax: Axes, texts: List[Any]) -> None:
301
+ """Adjust SNP label positions after axis limits are set.
302
+
303
+ Args:
304
+ ax: Matplotlib axes.
305
+ texts: List of text annotation objects from add_snp_labels().
306
+ """
307
+ from ..labels import adjust_snp_labels as _adjust_snp_labels
308
+
309
+ _adjust_snp_labels(ax, texts)
310
+
293
311
  def add_rectangle(
294
312
  self,
295
313
  ax: Axes,
@@ -714,6 +732,41 @@ class MatplotlibBackend:
714
732
  labelspacing=0.4,
715
733
  )
716
734
 
735
+ def add_effect_legend(
736
+ self,
737
+ ax: Axes,
738
+ effect_bins: List[Tuple[float, str, str]],
739
+ ) -> None:
740
+ """Add effect direction legend for colocalization plots.
741
+
742
+ Args:
743
+ ax: Matplotlib axes.
744
+ effect_bins: List of (threshold, label, color) tuples.
745
+ """
746
+ from matplotlib.lines import Line2D
747
+
748
+ legend_elements = []
749
+ for _threshold, label, color in effect_bins:
750
+ legend_elements.append(
751
+ Line2D(
752
+ [0],
753
+ [0],
754
+ marker="o",
755
+ color="w",
756
+ markerfacecolor=color,
757
+ markeredgecolor="black",
758
+ markersize=8,
759
+ label=label,
760
+ )
761
+ )
762
+
763
+ ax.legend(
764
+ handles=legend_elements,
765
+ loc="upper right",
766
+ fontsize=8,
767
+ framealpha=0.9,
768
+ )
769
+
717
770
  def axvline(
718
771
  self,
719
772
  ax: Axes,
@@ -806,3 +859,90 @@ class MatplotlibBackend:
806
859
  left=left, right=right, top=top, bottom=bottom, hspace=hspace
807
860
  )
808
861
  plt.ion()
862
+
863
+ def add_heatmap(
864
+ self,
865
+ ax: Axes,
866
+ data: Any,
867
+ x_coords: List[float],
868
+ y_coords: List[float],
869
+ cmap_colors: Optional[List[str]] = None,
870
+ vmin: float = 0.0,
871
+ vmax: float = 1.0,
872
+ mask_upper: bool = True,
873
+ ) -> Any:
874
+ """Render heatmap with optional triangular masking.
875
+
876
+ Args:
877
+ ax: Matplotlib axes.
878
+ data: 2D numpy array of values (NaN for missing).
879
+ x_coords: X coordinates for cells.
880
+ y_coords: Y coordinates for cells.
881
+ cmap_colors: Color gradient endpoints [start, end].
882
+ vmin: Minimum value for color scale.
883
+ vmax: Maximum value for color scale.
884
+ mask_upper: If True, mask upper triangle.
885
+
886
+ Returns:
887
+ AxesImage object for colorbar attachment.
888
+ """
889
+ import numpy as np
890
+ from matplotlib.colors import LinearSegmentedColormap
891
+
892
+ # Default white-to-red gradient
893
+ if cmap_colors is None:
894
+ cmap_colors = ["#FFFFFF", "#FF0000"]
895
+
896
+ cmap = LinearSegmentedColormap.from_list("ld_heatmap", cmap_colors, N=256)
897
+
898
+ # Mask upper triangle if requested
899
+ plot_data = data.copy()
900
+ if mask_upper:
901
+ mask = np.triu(np.ones_like(data, dtype=bool), k=1)
902
+ plot_data = np.ma.array(data, mask=mask)
903
+
904
+ # Compute extent from coordinates if non-trivial (genomic coordinates)
905
+ # This allows heatmap to align with regional plot x-axis
906
+ extent = None
907
+ if x_coords and len(x_coords) > 1:
908
+ x_min, x_max = min(x_coords), max(x_coords)
909
+ # Only use extent if coordinates are not simple indices
910
+ if x_max - x_min > len(x_coords):
911
+ # Genomic coordinates - use extent for alignment
912
+ # Add half-cell padding for proper cell centering
913
+ y_min, y_max = min(y_coords), max(y_coords)
914
+ extent = [x_min, x_max, y_min - 0.5, y_max + 0.5]
915
+
916
+ # Use imshow for heatmap rendering
917
+ im = ax.imshow(
918
+ plot_data,
919
+ cmap=cmap,
920
+ vmin=vmin,
921
+ vmax=vmax,
922
+ aspect="auto",
923
+ origin="lower",
924
+ extent=extent,
925
+ )
926
+ return im
927
+
928
+ def add_colorbar(
929
+ self,
930
+ ax: Axes,
931
+ mappable: Any,
932
+ label: str = "R²",
933
+ orientation: str = "vertical",
934
+ ) -> Any:
935
+ """Add colorbar legend for heatmap.
936
+
937
+ Args:
938
+ ax: Matplotlib axes.
939
+ mappable: AxesImage from add_heatmap.
940
+ label: Colorbar label.
941
+ orientation: "vertical" or "horizontal".
942
+
943
+ Returns:
944
+ Colorbar object.
945
+ """
946
+ cbar = plt.colorbar(mappable, ax=ax, orientation=orientation)
947
+ cbar.set_label(label)
948
+ return cbar
@@ -841,7 +841,12 @@ class PlotlyBackend:
841
841
  label_top_n: int,
842
842
  genes_df: Optional[pd.DataFrame],
843
843
  chrom: int,
844
- ) -> None:
844
+ adjust: bool = True,
845
+ ) -> List[Any]:
846
+ """No-op: Plotly uses hover tooltips instead of text labels."""
847
+ return []
848
+
849
+ def adjust_snp_labels(self, ax: Any, texts: List[Any]) -> None:
845
850
  """No-op: Plotly uses hover tooltips instead of text labels."""
846
851
  pass
847
852
 
@@ -887,6 +892,19 @@ class PlotlyBackend:
887
892
 
888
893
  self._configure_legend(fig, row, "legend", "r²")
889
894
 
895
+ def add_effect_legend(
896
+ self,
897
+ ax: Tuple[go.Figure, int],
898
+ effect_bins: List[Tuple[float, str, str]],
899
+ ) -> None:
900
+ """Add effect direction legend for colocalization plots."""
901
+ fig, row, col, _ = self._extract_row_col(ax)
902
+
903
+ for _, label, color in effect_bins:
904
+ self._add_legend_item(fig, row, label, color, "circle", 10, "legend")
905
+
906
+ self._configure_legend(fig, row, "legend", "Effect")
907
+
890
908
  def add_legend(
891
909
  self,
892
910
  ax: Tuple[go.Figure, int],
@@ -1219,3 +1237,85 @@ class PlotlyBackend:
1219
1237
  )
1220
1238
  }
1221
1239
  )
1240
+
1241
+ def add_heatmap(
1242
+ self,
1243
+ ax: Tuple[go.Figure, int],
1244
+ data: Any,
1245
+ x_coords: List[float],
1246
+ y_coords: List[float],
1247
+ cmap_colors: Optional[List[str]] = None,
1248
+ vmin: float = 0.0,
1249
+ vmax: float = 1.0,
1250
+ mask_upper: bool = True,
1251
+ ) -> Any:
1252
+ """Render heatmap with optional triangular masking.
1253
+
1254
+ Args:
1255
+ ax: Tuple of (figure, row_number).
1256
+ data: 2D numpy array of values (NaN for missing).
1257
+ x_coords: X coordinates for cells.
1258
+ y_coords: Y coordinates for cells.
1259
+ cmap_colors: Color gradient endpoints [start, end].
1260
+ vmin: Minimum value for color scale.
1261
+ vmax: Maximum value for color scale.
1262
+ mask_upper: If True, mask upper triangle.
1263
+
1264
+ Returns:
1265
+ Heatmap trace.
1266
+ """
1267
+ import numpy as np
1268
+
1269
+ fig, row, col, _ = self._extract_row_col(ax)
1270
+
1271
+ if cmap_colors is None:
1272
+ cmap_colors = ["#FFFFFF", "#FF0000"]
1273
+
1274
+ # Mask upper triangle by setting to NaN
1275
+ plot_data = data.copy()
1276
+ if mask_upper:
1277
+ for i in range(data.shape[0]):
1278
+ for j in range(i + 1, data.shape[1]):
1279
+ plot_data[i, j] = np.nan
1280
+
1281
+ # Replace NaN with None for Plotly
1282
+ z = np.where(np.isnan(plot_data), None, plot_data)
1283
+
1284
+ colorscale = [[0, cmap_colors[0]], [1, cmap_colors[1]]]
1285
+
1286
+ trace = go.Heatmap(
1287
+ z=z.tolist(),
1288
+ x=list(range(len(x_coords))),
1289
+ y=list(range(len(y_coords))),
1290
+ colorscale=colorscale,
1291
+ zmin=vmin,
1292
+ zmax=vmax,
1293
+ showscale=True,
1294
+ colorbar=dict(title="R²"),
1295
+ )
1296
+ fig.add_trace(trace, row=row, col=col)
1297
+ return trace
1298
+
1299
+ def add_colorbar(
1300
+ self,
1301
+ ax: Tuple[go.Figure, int],
1302
+ mappable: Any,
1303
+ label: str = "R²",
1304
+ orientation: str = "vertical",
1305
+ ) -> Any:
1306
+ """Add colorbar legend for heatmap.
1307
+
1308
+ For Plotly, colorbar is added via showscale=True in add_heatmap.
1309
+ This method is a no-op but kept for API consistency.
1310
+
1311
+ Args:
1312
+ ax: Tuple of (figure, row_number).
1313
+ mappable: Heatmap trace (unused).
1314
+ label: Colorbar label (unused, set in add_heatmap).
1315
+ orientation: Orientation (unused).
1316
+
1317
+ Returns:
1318
+ None.
1319
+ """
1320
+ # Colorbar is configured in add_heatmap via showscale=True
1321
+ pass