pylocuszoom 0.5.0__py3-none-any.whl → 0.8.0__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.
@@ -12,17 +12,40 @@ from matplotlib.figure import Figure
12
12
  from matplotlib.patches import Polygon, Rectangle
13
13
  from matplotlib.ticker import FuncFormatter, MaxNLocator
14
14
 
15
+ from . import register_backend
15
16
 
17
+
18
+ @register_backend("matplotlib")
16
19
  class MatplotlibBackend:
17
20
  """Matplotlib backend for static plot generation.
18
21
 
19
22
  This is the default backend, producing publication-quality static plots
20
23
  suitable for papers and presentations.
24
+
25
+ Capability Properties:
26
+ supports_snp_labels: True - uses adjustText for automatic label positioning.
27
+ supports_hover: False - static plots don't support hover tooltips.
28
+ supports_secondary_axis: True - supports twin y-axis via twinx().
21
29
  """
22
30
 
23
- def __init__(self) -> None:
24
- """Initialize the matplotlib backend."""
25
- pass
31
+ # =========================================================================
32
+ # Capability Properties
33
+ # =========================================================================
34
+
35
+ @property
36
+ def supports_snp_labels(self) -> bool:
37
+ """Matplotlib supports SNP labels via adjustText."""
38
+ return True
39
+
40
+ @property
41
+ def supports_hover(self) -> bool:
42
+ """Matplotlib does not support hover tooltips."""
43
+ return False
44
+
45
+ @property
46
+ def supports_secondary_axis(self) -> bool:
47
+ """Matplotlib supports twin y-axis."""
48
+ return True
26
49
 
27
50
  def create_figure(
28
51
  self,
@@ -164,6 +187,67 @@ class MatplotlibBackend:
164
187
  x, y, text, fontsize=fontsize, ha=ha, va=va, rotation=rotation, color=color
165
188
  )
166
189
 
190
+ def add_panel_label(
191
+ self,
192
+ ax: Axes,
193
+ label: str,
194
+ x_frac: float = 0.02,
195
+ y_frac: float = 0.95,
196
+ ) -> None:
197
+ """Add label text at fractional position in panel.
198
+
199
+ Args:
200
+ ax: Matplotlib axes.
201
+ label: Label text (e.g., "A", "B").
202
+ x_frac: Horizontal position as fraction of axes (0-1).
203
+ y_frac: Vertical position as fraction of axes (0-1).
204
+ """
205
+ ax.annotate(
206
+ label,
207
+ xy=(x_frac, y_frac),
208
+ xycoords="axes fraction",
209
+ fontsize=10,
210
+ fontweight="bold",
211
+ ha="left",
212
+ va="top",
213
+ )
214
+
215
+ def add_snp_labels(
216
+ self,
217
+ ax: Axes,
218
+ df: pd.DataFrame,
219
+ pos_col: str,
220
+ neglog10p_col: str,
221
+ rs_col: str,
222
+ label_top_n: int,
223
+ genes_df: Optional[pd.DataFrame],
224
+ chrom: int,
225
+ ) -> None:
226
+ """Add SNP labels using adjustText.
227
+
228
+ Args:
229
+ ax: Matplotlib axes.
230
+ df: DataFrame with SNP data.
231
+ pos_col: Column name for position.
232
+ neglog10p_col: Column name for -log10(p-value).
233
+ rs_col: Column name for SNP ID.
234
+ label_top_n: Number of top SNPs to label.
235
+ genes_df: Gene annotations (unused, for signature compatibility).
236
+ chrom: Chromosome number (unused, for signature compatibility).
237
+ """
238
+ from ..labels import add_snp_labels as _add_snp_labels
239
+
240
+ _add_snp_labels(
241
+ ax,
242
+ df,
243
+ pos_col=pos_col,
244
+ neglog10p_col=neglog10p_col,
245
+ rs_col=rs_col,
246
+ label_top_n=label_top_n,
247
+ genes_df=genes_df,
248
+ chrom=chrom,
249
+ )
250
+
167
251
  def add_rectangle(
168
252
  self,
169
253
  ax: Axes,
@@ -225,6 +309,17 @@ class MatplotlibBackend:
225
309
  """Set y-axis label."""
226
310
  ax.set_ylabel(label, fontsize=fontsize)
227
311
 
312
+ def set_yticks(
313
+ self,
314
+ ax: Axes,
315
+ positions: List[float],
316
+ labels: List[str],
317
+ fontsize: int = 10,
318
+ ) -> None:
319
+ """Set y-axis tick positions and labels."""
320
+ ax.set_yticks(positions)
321
+ ax.set_yticklabels(labels, fontsize=fontsize)
322
+
228
323
  def set_title(self, ax: Axes, title: str, fontsize: int = 14) -> None:
229
324
  """Set panel title."""
230
325
  ax.set_title(
@@ -238,6 +333,119 @@ class MatplotlibBackend:
238
333
  """Create a secondary y-axis sharing the same x-axis."""
239
334
  return ax.twinx()
240
335
 
336
+ def line_secondary(
337
+ self,
338
+ ax: Axes,
339
+ x: pd.Series,
340
+ y: pd.Series,
341
+ color: str = "blue",
342
+ linewidth: float = 1.5,
343
+ alpha: float = 1.0,
344
+ linestyle: str = "-",
345
+ label: Optional[str] = None,
346
+ yaxis_name: Any = None,
347
+ ) -> Any:
348
+ """Create line on secondary y-axis.
349
+
350
+ For matplotlib, the ax should already be a twin axis from create_twin_axis().
351
+ The yaxis_name parameter is ignored (provided for interface compatibility).
352
+
353
+ Args:
354
+ ax: Secondary axes from create_twin_axis().
355
+ x: X-axis values.
356
+ y: Y-axis values.
357
+ color: Line color.
358
+ linewidth: Line width.
359
+ alpha: Transparency.
360
+ linestyle: Line style.
361
+ label: Legend label.
362
+ yaxis_name: Ignored for matplotlib.
363
+
364
+ Returns:
365
+ The line object.
366
+ """
367
+ return self.line(
368
+ ax,
369
+ x,
370
+ y,
371
+ color=color,
372
+ linewidth=linewidth,
373
+ alpha=alpha,
374
+ linestyle=linestyle,
375
+ label=label,
376
+ )
377
+
378
+ def fill_between_secondary(
379
+ self,
380
+ ax: Axes,
381
+ x: pd.Series,
382
+ y1: Union[float, pd.Series],
383
+ y2: Union[float, pd.Series],
384
+ color: str = "blue",
385
+ alpha: float = 0.3,
386
+ yaxis_name: Any = None,
387
+ ) -> Any:
388
+ """Fill area on secondary y-axis.
389
+
390
+ For matplotlib, the ax should already be a twin axis from create_twin_axis().
391
+ The yaxis_name parameter is ignored (provided for interface compatibility).
392
+
393
+ Args:
394
+ ax: Secondary axes from create_twin_axis().
395
+ x: X-axis values.
396
+ y1: Lower y boundary.
397
+ y2: Upper y boundary.
398
+ color: Fill color.
399
+ alpha: Transparency.
400
+ yaxis_name: Ignored for matplotlib.
401
+
402
+ Returns:
403
+ The fill object.
404
+ """
405
+ return self.fill_between(ax, x, y1, y2, color=color, alpha=alpha)
406
+
407
+ def set_secondary_ylim(
408
+ self,
409
+ ax: Axes,
410
+ bottom: float,
411
+ top: float,
412
+ yaxis_name: Any = None,
413
+ ) -> None:
414
+ """Set secondary y-axis limits.
415
+
416
+ For matplotlib, the ax should already be a twin axis from create_twin_axis().
417
+ The yaxis_name parameter is ignored (provided for interface compatibility).
418
+
419
+ Args:
420
+ ax: Secondary axes from create_twin_axis().
421
+ bottom: Minimum y value.
422
+ top: Maximum y value.
423
+ yaxis_name: Ignored for matplotlib.
424
+ """
425
+ self.set_ylim(ax, bottom, top)
426
+
427
+ def set_secondary_ylabel(
428
+ self,
429
+ ax: Axes,
430
+ label: str,
431
+ color: str = "black",
432
+ fontsize: int = 10,
433
+ yaxis_name: Any = None,
434
+ ) -> None:
435
+ """Set secondary y-axis label.
436
+
437
+ For matplotlib, the ax should already be a twin axis from create_twin_axis().
438
+ The yaxis_name parameter is ignored (provided for interface compatibility).
439
+
440
+ Args:
441
+ ax: Secondary axes from create_twin_axis().
442
+ label: Label text.
443
+ color: Label color.
444
+ fontsize: Font size.
445
+ yaxis_name: Ignored for matplotlib.
446
+ """
447
+ ax.set_ylabel(label, fontsize=fontsize, color=color)
448
+
241
449
  def add_legend(
242
450
  self,
243
451
  ax: Axes,
@@ -266,6 +474,10 @@ class MatplotlibBackend:
266
474
  for spine in spines:
267
475
  ax.spines[spine].set_visible(False)
268
476
 
477
+ def hide_yaxis(self, ax: Axes) -> None:
478
+ """Hide y-axis ticks, labels, and line."""
479
+ ax.yaxis.set_visible(False)
480
+
269
481
  def format_xaxis_mb(self, ax: Axes) -> None:
270
482
  """Format x-axis to show megabase values."""
271
483
  ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x / 1e6:.2f}"))
@@ -394,6 +606,123 @@ class MatplotlibBackend:
394
606
  """Add simple legend for labeled scatter data."""
395
607
  ax.legend(loc=loc, fontsize=9)
396
608
 
609
+ def add_ld_legend(
610
+ self,
611
+ ax: Axes,
612
+ ld_bins: List[Tuple[float, str, str]],
613
+ lead_snp_color: str,
614
+ ) -> None:
615
+ """Add LD color legend using matplotlib patches.
616
+
617
+ Args:
618
+ ax: Matplotlib axes.
619
+ ld_bins: List of (threshold, label, color) tuples defining LD bins.
620
+ lead_snp_color: Color for lead SNP marker in legend.
621
+ """
622
+ from matplotlib.lines import Line2D
623
+ from matplotlib.patches import Patch
624
+
625
+ from ..colors import get_ld_color_palette
626
+
627
+ palette = get_ld_color_palette()
628
+ legend_elements = [
629
+ Line2D(
630
+ [0],
631
+ [0],
632
+ marker="D",
633
+ color="w",
634
+ markerfacecolor=lead_snp_color,
635
+ markeredgecolor="black",
636
+ markersize=6,
637
+ label="Lead SNP",
638
+ ),
639
+ ]
640
+ for _threshold, label, _color in ld_bins:
641
+ legend_elements.append(
642
+ Patch(facecolor=palette[label], edgecolor="black", label=label)
643
+ )
644
+ ax.legend(
645
+ handles=legend_elements,
646
+ loc="upper right",
647
+ fontsize=9,
648
+ frameon=True,
649
+ framealpha=0.9,
650
+ title=r"$r^2$",
651
+ title_fontsize=10,
652
+ handlelength=1.5,
653
+ handleheight=1.0,
654
+ labelspacing=0.4,
655
+ )
656
+
657
+ def axvline(
658
+ self,
659
+ ax: Axes,
660
+ x: float,
661
+ color: str = "grey",
662
+ linestyle: str = "--",
663
+ linewidth: float = 1.0,
664
+ alpha: float = 1.0,
665
+ zorder: int = 1,
666
+ ) -> Any:
667
+ """Add a vertical line across the axes."""
668
+ return ax.axvline(
669
+ x=x,
670
+ color=color,
671
+ linestyle=linestyle,
672
+ linewidth=linewidth,
673
+ alpha=alpha,
674
+ zorder=zorder,
675
+ )
676
+
677
+ def hbar(
678
+ self,
679
+ ax: Axes,
680
+ y: pd.Series,
681
+ width: pd.Series,
682
+ height: float = 0.8,
683
+ left: Union[float, pd.Series] = 0,
684
+ color: Union[str, List[str]] = "blue",
685
+ edgecolor: str = "black",
686
+ linewidth: float = 0.5,
687
+ zorder: int = 2,
688
+ ) -> Any:
689
+ """Create horizontal bar chart."""
690
+ return ax.barh(
691
+ y=y,
692
+ width=width,
693
+ height=height,
694
+ left=left,
695
+ color=color,
696
+ edgecolor=edgecolor,
697
+ linewidth=linewidth,
698
+ zorder=zorder,
699
+ )
700
+
701
+ def errorbar_h(
702
+ self,
703
+ ax: Axes,
704
+ x: pd.Series,
705
+ y: pd.Series,
706
+ xerr_lower: pd.Series,
707
+ xerr_upper: pd.Series,
708
+ color: str = "black",
709
+ linewidth: float = 1.5,
710
+ capsize: float = 3,
711
+ zorder: int = 3,
712
+ ) -> Any:
713
+ """Add horizontal error bars."""
714
+ xerr = [xerr_lower.values, xerr_upper.values]
715
+ return ax.errorbar(
716
+ x=x,
717
+ y=y,
718
+ xerr=xerr,
719
+ fmt="none",
720
+ ecolor=color,
721
+ elinewidth=linewidth,
722
+ capsize=capsize,
723
+ zorder=zorder,
724
+ )
725
+
397
726
  def finalize_layout(
398
727
  self,
399
728
  fig: Figure,
@@ -9,7 +9,10 @@ import pandas as pd
9
9
  import plotly.graph_objects as go
10
10
  from plotly.subplots import make_subplots
11
11
 
12
+ from . import convert_latex_to_unicode, register_backend
12
13
 
14
+
15
+ @register_backend("plotly")
13
16
  class PlotlyBackend:
14
17
  """Plotly backend for interactive plot generation.
15
18
 
@@ -20,15 +23,35 @@ class PlotlyBackend:
20
23
  - Nearest gene
21
24
  """
22
25
 
23
- def __init__(self) -> None:
24
- """Initialize the plotly backend."""
25
- self._marker_symbols = {
26
- "o": "circle",
27
- "D": "diamond",
28
- "s": "square",
29
- "^": "triangle-up",
30
- "v": "triangle-down",
31
- }
26
+ # Class constants for style mappings
27
+ _MARKER_SYMBOLS = {
28
+ "o": "circle",
29
+ "D": "diamond",
30
+ "s": "square",
31
+ "^": "triangle-up",
32
+ "v": "triangle-down",
33
+ }
34
+ _DASH_MAP = {
35
+ "-": "solid",
36
+ "--": "dash",
37
+ ":": "dot",
38
+ "-.": "dashdot",
39
+ }
40
+
41
+ @property
42
+ def supports_snp_labels(self) -> bool:
43
+ """Plotly uses hover tooltips instead of labels."""
44
+ return False
45
+
46
+ @property
47
+ def supports_hover(self) -> bool:
48
+ """Plotly supports hover tooltips."""
49
+ return True
50
+
51
+ @property
52
+ def supports_secondary_axis(self) -> bool:
53
+ """Plotly supports secondary y-axis."""
54
+ return True
32
55
 
33
56
  def create_figure(
34
57
  self,
@@ -111,7 +134,7 @@ class PlotlyBackend:
111
134
  fig, row = ax
112
135
 
113
136
  # Convert matplotlib marker to plotly symbol
114
- symbol = self._marker_symbols.get(marker, "circle")
137
+ symbol = self._MARKER_SYMBOLS.get(marker, "circle")
115
138
 
116
139
  # Convert size (matplotlib uses area, plotly uses diameter)
117
140
  if isinstance(sizes, (int, float)):
@@ -178,15 +201,7 @@ class PlotlyBackend:
178
201
  ) -> Any:
179
202
  """Create a line plot on the given panel."""
180
203
  fig, row = ax
181
-
182
- # Convert linestyle
183
- dash_map = {
184
- "-": "solid",
185
- "--": "dash",
186
- ":": "dot",
187
- "-.": "dashdot",
188
- }
189
- dash = dash_map.get(linestyle, "solid")
204
+ dash = self._DASH_MAP.get(linestyle, "solid")
190
205
 
191
206
  trace = go.Scatter(
192
207
  x=x,
@@ -244,9 +259,7 @@ class PlotlyBackend:
244
259
  ) -> Any:
245
260
  """Add a horizontal line across the panel."""
246
261
  fig, row = ax
247
-
248
- dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
249
- dash = dash_map.get(linestyle, "dash")
262
+ dash = self._DASH_MAP.get(linestyle, "dash")
250
263
 
251
264
  fig.add_hline(
252
265
  y=y,
@@ -384,6 +397,26 @@ class PlotlyBackend:
384
397
  }
385
398
  )
386
399
 
400
+ def set_yticks(
401
+ self,
402
+ ax: Tuple[go.Figure, int],
403
+ positions: List[float],
404
+ labels: List[str],
405
+ fontsize: int = 10,
406
+ ) -> None:
407
+ """Set y-axis tick positions and labels."""
408
+ fig, row = ax
409
+ fig.update_layout(
410
+ **{
411
+ self._axis_name("yaxis", row): dict(
412
+ tickmode="array",
413
+ tickvals=positions,
414
+ ticktext=labels,
415
+ tickfont=dict(size=fontsize),
416
+ )
417
+ }
418
+ )
419
+
387
420
  def _axis_name(self, axis: str, row: int) -> str:
388
421
  """Get Plotly axis name for a given row.
389
422
 
@@ -404,19 +437,7 @@ class PlotlyBackend:
404
437
 
405
438
  def _convert_label(self, label: str) -> str:
406
439
  """Convert LaTeX-style labels to Unicode for Plotly display."""
407
- conversions = [
408
- (r"$-\log_{10}$ P", "-log₁₀(P)"),
409
- (r"$-\log_{10}$", "-log₁₀"),
410
- (r"\log_{10}", "log₁₀"),
411
- (r"$r^2$", "r²"),
412
- (r"$R^2$", "R²"),
413
- ]
414
- for latex, unicode_str in conversions:
415
- if latex in label:
416
- label = label.replace(latex, unicode_str)
417
- # Remove any remaining $ markers
418
- label = label.replace("$", "")
419
- return label
440
+ return convert_latex_to_unicode(label)
420
441
 
421
442
  def set_title(
422
443
  self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
@@ -461,9 +482,7 @@ class PlotlyBackend:
461
482
  ) -> Any:
462
483
  """Create a line plot on secondary y-axis."""
463
484
  fig, row = ax
464
-
465
- dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
466
- dash = dash_map.get(linestyle, "solid")
485
+ dash = self._DASH_MAP.get(linestyle, "solid")
467
486
 
468
487
  trace = go.Scatter(
469
488
  x=x,
@@ -609,6 +628,41 @@ class PlotlyBackend:
609
628
  }
610
629
  )
611
630
 
631
+ def add_snp_labels(
632
+ self,
633
+ ax: Tuple[go.Figure, int],
634
+ df: pd.DataFrame,
635
+ pos_col: str,
636
+ neglog10p_col: str,
637
+ rs_col: str,
638
+ label_top_n: int,
639
+ genes_df: Optional[pd.DataFrame],
640
+ chrom: int,
641
+ ) -> None:
642
+ """No-op: Plotly uses hover tooltips instead of text labels."""
643
+ pass
644
+
645
+ def add_panel_label(
646
+ self,
647
+ ax: Tuple[go.Figure, int],
648
+ label: str,
649
+ x_frac: float = 0.02,
650
+ y_frac: float = 0.95,
651
+ ) -> None:
652
+ """Add label text at fractional position in panel."""
653
+ fig, row = ax
654
+ fig.add_annotation(
655
+ text=f"<b>{label}</b>",
656
+ xref="x domain",
657
+ yref="y domain",
658
+ x=x_frac,
659
+ y=y_frac,
660
+ showarrow=False,
661
+ font=dict(size=12),
662
+ row=row,
663
+ col=1,
664
+ )
665
+
612
666
  def add_ld_legend(
613
667
  self,
614
668
  ax: Tuple[go.Figure, int],
@@ -782,6 +836,101 @@ class PlotlyBackend:
782
836
  )
783
837
  )
784
838
 
839
+ def axvline(
840
+ self,
841
+ ax: Tuple[go.Figure, int],
842
+ x: float,
843
+ color: str = "grey",
844
+ linestyle: str = "--",
845
+ linewidth: float = 1.0,
846
+ alpha: float = 1.0,
847
+ zorder: int = 1,
848
+ ) -> Any:
849
+ """Add a vertical line across the panel."""
850
+ fig, row = ax
851
+ dash = self._DASH_MAP.get(linestyle, "dash")
852
+
853
+ fig.add_vline(
854
+ x=x,
855
+ line_dash=dash,
856
+ line_color=color,
857
+ line_width=linewidth,
858
+ opacity=alpha,
859
+ row=row,
860
+ col=1,
861
+ )
862
+
863
+ def hbar(
864
+ self,
865
+ ax: Tuple[go.Figure, int],
866
+ y: pd.Series,
867
+ width: pd.Series,
868
+ height: float = 0.8,
869
+ left: Union[float, pd.Series] = 0,
870
+ color: Union[str, List[str]] = "blue",
871
+ edgecolor: str = "black",
872
+ linewidth: float = 0.5,
873
+ zorder: int = 2,
874
+ ) -> Any:
875
+ """Create horizontal bar chart."""
876
+ fig, row = ax
877
+
878
+ # Convert left to array if scalar
879
+ if isinstance(left, (int, float)):
880
+ left_arr = [left] * len(y)
881
+ else:
882
+ left_arr = list(left) if hasattr(left, "tolist") else left
883
+
884
+ trace = go.Bar(
885
+ y=y,
886
+ x=width,
887
+ orientation="h",
888
+ base=left_arr,
889
+ marker=dict(
890
+ color=color,
891
+ line=dict(color=edgecolor, width=linewidth),
892
+ ),
893
+ showlegend=False,
894
+ )
895
+
896
+ fig.add_trace(trace, row=row, col=1)
897
+ return trace
898
+
899
+ def errorbar_h(
900
+ self,
901
+ ax: Tuple[go.Figure, int],
902
+ x: pd.Series,
903
+ y: pd.Series,
904
+ xerr_lower: pd.Series,
905
+ xerr_upper: pd.Series,
906
+ color: str = "black",
907
+ linewidth: float = 1.5,
908
+ capsize: float = 3,
909
+ zorder: int = 3,
910
+ ) -> Any:
911
+ """Add horizontal error bars."""
912
+ fig, row = ax
913
+
914
+ trace = go.Scatter(
915
+ x=x,
916
+ y=y,
917
+ mode="markers",
918
+ marker=dict(size=0),
919
+ error_x=dict(
920
+ type="data",
921
+ symmetric=False,
922
+ array=xerr_upper,
923
+ arrayminus=xerr_lower,
924
+ color=color,
925
+ thickness=linewidth,
926
+ width=capsize,
927
+ ),
928
+ showlegend=False,
929
+ )
930
+
931
+ fig.add_trace(trace, row=row, col=1)
932
+ return trace
933
+
785
934
  def finalize_layout(
786
935
  self,
787
936
  fig: go.Figure,