pylocuszoom 0.3.0__py3-none-any.whl → 0.5.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.
@@ -71,6 +71,20 @@ class PlotlyBackend:
71
71
  template="plotly_white",
72
72
  )
73
73
 
74
+ # Style all panels for clean LocusZoom appearance
75
+ axis_style = dict(
76
+ showgrid=False,
77
+ showline=True,
78
+ linecolor="black",
79
+ ticks="outside",
80
+ minor_ticks="",
81
+ zeroline=False,
82
+ )
83
+ for row in range(1, n_panels + 1):
84
+ xaxis = self._axis_name("xaxis", row)
85
+ yaxis = self._axis_name("yaxis", row)
86
+ fig.update_layout(**{xaxis: axis_style, yaxis: axis_style})
87
+
74
88
  # Return (fig, row) tuples for each panel
75
89
  # This matches the expected ax parameter format for all methods
76
90
  panel_refs = [(fig, row) for row in range(1, n_panels + 1)]
@@ -112,9 +126,9 @@ class PlotlyBackend:
112
126
  hovertemplate = "<b>%{customdata[0]}</b><br>"
113
127
  for i, col in enumerate(hover_cols[1:], 1):
114
128
  col_lower = col.lower()
115
- if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
129
+ if col_lower in ("p-value", "pval", "p_value"):
116
130
  hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
117
- elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
131
+ elif any(x in col_lower for x in ("r2", "r²", "ld")):
118
132
  hovertemplate += f"{col}: %{{customdata[{i}]:.3f}}<br>"
119
133
  elif "pos" in col_lower:
120
134
  hovertemplate += f"{col}: %{{customdata[{i}]:,.0f}}<br>"
@@ -335,25 +349,25 @@ class PlotlyBackend:
335
349
  def set_xlim(self, ax: Tuple[go.Figure, int], left: float, right: float) -> None:
336
350
  """Set x-axis limits."""
337
351
  fig, row = ax
338
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
339
- fig.update_layout(**{xaxis: dict(range=[left, right])})
352
+ fig.update_layout(**{self._axis_name("xaxis", row): dict(range=[left, right])})
340
353
 
341
354
  def set_ylim(self, ax: Tuple[go.Figure, int], bottom: float, top: float) -> None:
342
355
  """Set y-axis limits."""
343
356
  fig, row = ax
344
- yaxis = f"yaxis{row}" if row > 1 else "yaxis"
345
- fig.update_layout(**{yaxis: dict(range=[bottom, top])})
357
+ fig.update_layout(**{self._axis_name("yaxis", row): dict(range=[bottom, top])})
346
358
 
347
359
  def set_xlabel(
348
360
  self, ax: Tuple[go.Figure, int], label: str, fontsize: int = 12
349
361
  ) -> None:
350
362
  """Set x-axis label."""
351
363
  fig, row = ax
352
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
353
- # Convert LaTeX-style labels to Unicode for Plotly
354
364
  label = self._convert_label(label)
355
365
  fig.update_layout(
356
- **{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
366
+ **{
367
+ self._axis_name("xaxis", row): dict(
368
+ title=dict(text=label, font=dict(size=fontsize))
369
+ )
370
+ }
357
371
  )
358
372
 
359
373
  def set_ylabel(
@@ -361,16 +375,35 @@ class PlotlyBackend:
361
375
  ) -> None:
362
376
  """Set y-axis label."""
363
377
  fig, row = ax
364
- yaxis = f"yaxis{row}" if row > 1 else "yaxis"
365
- # Convert LaTeX-style labels to Unicode for Plotly
366
378
  label = self._convert_label(label)
367
379
  fig.update_layout(
368
- **{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
380
+ **{
381
+ self._axis_name("yaxis", row): dict(
382
+ title=dict(text=label, font=dict(size=fontsize))
383
+ )
384
+ }
369
385
  )
370
386
 
387
+ def _axis_name(self, axis: str, row: int) -> str:
388
+ """Get Plotly axis name for a given row.
389
+
390
+ Plotly names axes as 'xaxis', 'yaxis' for row 1, and
391
+ 'xaxis2', 'yaxis2', etc. for subsequent rows.
392
+ """
393
+ return f"{axis}{row}" if row > 1 else axis
394
+
395
+ def _get_legend_position(self, loc: str) -> dict:
396
+ """Map matplotlib-style legend location to Plotly position dict."""
397
+ loc_map = {
398
+ "upper left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
399
+ "upper right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
400
+ "lower left": dict(x=0.01, y=0.01, xanchor="left", yanchor="bottom"),
401
+ "lower right": dict(x=0.99, y=0.01, xanchor="right", yanchor="bottom"),
402
+ }
403
+ return loc_map.get(loc, loc_map["upper left"])
404
+
371
405
  def _convert_label(self, label: str) -> str:
372
406
  """Convert LaTeX-style labels to Unicode for Plotly display."""
373
- # Common conversions for genomics plots
374
407
  conversions = [
375
408
  (r"$-\log_{10}$ P", "-log₁₀(P)"),
376
409
  (r"$-\log_{10}$", "-log₁₀"),
@@ -487,7 +520,9 @@ class PlotlyBackend:
487
520
  ) -> None:
488
521
  """Set secondary y-axis limits."""
489
522
  fig, row = ax
490
- yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
523
+ yaxis_key = (
524
+ "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
525
+ )
491
526
  fig.update_layout(**{yaxis_key: dict(range=[bottom, top])})
492
527
 
493
528
  def set_secondary_ylabel(
@@ -501,7 +536,9 @@ class PlotlyBackend:
501
536
  """Set secondary y-axis label."""
502
537
  fig, row = ax
503
538
  label = self._convert_label(label)
504
- yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
539
+ yaxis_key = (
540
+ "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
541
+ )
505
542
  fig.update_layout(
506
543
  **{
507
544
  yaxis_key: dict(
@@ -511,48 +548,87 @@ class PlotlyBackend:
511
548
  }
512
549
  )
513
550
 
551
+ def _get_panel_y_top(self, fig: go.Figure, row: int) -> float:
552
+ """Get the top y-coordinate (in paper coords) for a subplot row.
553
+
554
+ Plotly subplots have y-axis domains that define their vertical position.
555
+ This returns the top of the domain for positioning legends.
556
+ """
557
+ yaxis = getattr(fig.layout, self._axis_name("yaxis", row), None)
558
+ if yaxis and yaxis.domain:
559
+ return yaxis.domain[1]
560
+ return 0.99
561
+
562
+ def _add_legend_item(
563
+ self,
564
+ fig: go.Figure,
565
+ row: int,
566
+ name: str,
567
+ color: str,
568
+ symbol: str,
569
+ size: int,
570
+ legend_group: str,
571
+ ) -> None:
572
+ """Add an invisible scatter trace for a legend entry."""
573
+ fig.add_trace(
574
+ go.Scatter(
575
+ x=[None],
576
+ y=[None],
577
+ mode="markers",
578
+ marker=dict(
579
+ symbol=symbol,
580
+ size=size,
581
+ color=color,
582
+ line=dict(color="black", width=0.5),
583
+ ),
584
+ name=name,
585
+ showlegend=True,
586
+ legend=legend_group,
587
+ ),
588
+ row=row,
589
+ col=1,
590
+ )
591
+
592
+ def _configure_legend(
593
+ self, fig: go.Figure, row: int, legend_key: str, title: str
594
+ ) -> None:
595
+ """Configure legend position and styling."""
596
+ y_pos = self._get_panel_y_top(fig, row)
597
+ fig.update_layout(
598
+ **{
599
+ legend_key: dict(
600
+ title=dict(text=title),
601
+ x=0.99,
602
+ y=y_pos,
603
+ xanchor="right",
604
+ yanchor="top",
605
+ bgcolor="rgba(255,255,255,0.9)",
606
+ bordercolor="black",
607
+ borderwidth=1,
608
+ )
609
+ }
610
+ )
611
+
514
612
  def add_ld_legend(
515
613
  self,
516
614
  ax: Tuple[go.Figure, int],
517
615
  ld_bins: List[Tuple[float, str, str]],
518
616
  lead_snp_color: str,
519
617
  ) -> None:
520
- """Add LD color legend using invisible scatter traces."""
618
+ """Add LD color legend using invisible scatter traces.
619
+
620
+ Uses Plotly's separate legend feature (legend="legend") so LD legend
621
+ can be positioned independently from eQTL and fine-mapping legends.
622
+ """
521
623
  fig, row = ax
522
624
 
523
- # Add LD bin markers (no lead SNP - it's shown in the actual plot)
625
+ self._add_legend_item(
626
+ fig, row, "Lead SNP", lead_snp_color, "diamond", 12, "legend"
627
+ )
524
628
  for _, label, color in ld_bins:
525
- fig.add_trace(
526
- go.Scatter(
527
- x=[None],
528
- y=[None],
529
- mode="markers",
530
- marker=dict(
531
- symbol="square",
532
- size=10,
533
- color=color,
534
- line=dict(color="black", width=0.5),
535
- ),
536
- name=label,
537
- showlegend=True,
538
- ),
539
- row=row,
540
- col=1,
541
- )
629
+ self._add_legend_item(fig, row, label, color, "square", 10, "legend")
542
630
 
543
- # Position legend
544
- fig.update_layout(
545
- legend=dict(
546
- x=0.99,
547
- y=0.99,
548
- xanchor="right",
549
- yanchor="top",
550
- title=dict(text="r²"),
551
- bgcolor="rgba(255,255,255,0.9)",
552
- bordercolor="black",
553
- borderwidth=1,
554
- )
555
- )
631
+ self._configure_legend(fig, row, "legend", "r²")
556
632
 
557
633
  def add_legend(
558
634
  self,
@@ -568,16 +644,7 @@ class PlotlyBackend:
568
644
  This method updates legend positioning.
569
645
  """
570
646
  fig, _ = ax
571
-
572
- # Map matplotlib locations to plotly
573
- loc_map = {
574
- "upper left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
575
- "upper right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
576
- "lower left": dict(x=0.01, y=0.01, xanchor="left", yanchor="bottom"),
577
- "lower right": dict(x=0.99, y=0.01, xanchor="right", yanchor="bottom"),
578
- }
579
-
580
- legend_pos = loc_map.get(loc, loc_map["upper left"])
647
+ legend_pos = self._get_legend_position(loc)
581
648
  fig.update_layout(
582
649
  legend=dict(
583
650
  **legend_pos,
@@ -597,6 +664,20 @@ class PlotlyBackend:
597
664
  # No action needed - method exists for API compatibility
598
665
  pass
599
666
 
667
+ def hide_yaxis(self, ax: Tuple[go.Figure, int]) -> None:
668
+ """Hide y-axis ticks, labels, line, and grid for gene track panels."""
669
+ fig, row = ax
670
+ fig.update_layout(
671
+ **{
672
+ self._axis_name("yaxis", row): dict(
673
+ showticklabels=False,
674
+ showline=False,
675
+ showgrid=False,
676
+ ticks="",
677
+ )
678
+ }
679
+ )
680
+
600
681
  def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
601
682
  """Format x-axis to show megabase values.
602
683
 
@@ -634,6 +715,73 @@ class PlotlyBackend:
634
715
  """Close the figure (no-op for plotly)."""
635
716
  pass
636
717
 
718
+ def add_eqtl_legend(
719
+ self,
720
+ ax: Tuple[go.Figure, int],
721
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
722
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
723
+ ) -> None:
724
+ """Add eQTL effect size legend using invisible scatter traces.
725
+
726
+ Uses Plotly's separate legend feature (legend="legend2") so eQTL legend
727
+ is positioned independently below the LD legend.
728
+ """
729
+ fig, row = ax
730
+
731
+ for _, _, label, color in eqtl_positive_bins:
732
+ self._add_legend_item(fig, row, label, color, "triangle-up", 10, "legend2")
733
+ for _, _, label, color in eqtl_negative_bins:
734
+ self._add_legend_item(
735
+ fig, row, label, color, "triangle-down", 10, "legend2"
736
+ )
737
+
738
+ self._configure_legend(fig, row, "legend2", "eQTL effect")
739
+
740
+ def add_finemapping_legend(
741
+ self,
742
+ ax: Tuple[go.Figure, int],
743
+ credible_sets: List[int],
744
+ get_color_func: Any,
745
+ ) -> None:
746
+ """Add fine-mapping credible set legend using invisible scatter traces.
747
+
748
+ Uses Plotly's separate legend feature (legend="legend2") so fine-mapping
749
+ legend is positioned independently below the LD legend.
750
+ """
751
+ if not credible_sets:
752
+ return
753
+
754
+ fig, row = ax
755
+
756
+ for cs_id in credible_sets:
757
+ self._add_legend_item(
758
+ fig, row, f"CS{cs_id}", get_color_func(cs_id), "circle", 10, "legend2"
759
+ )
760
+
761
+ self._configure_legend(fig, row, "legend2", "Credible sets")
762
+
763
+ def add_simple_legend(
764
+ self,
765
+ ax: Tuple[go.Figure, int],
766
+ label: str,
767
+ loc: str = "upper right",
768
+ ) -> None:
769
+ """Add simple legend positioning.
770
+
771
+ Plotly handles legends automatically from trace names.
772
+ This just positions the legend.
773
+ """
774
+ fig, _ = ax
775
+ legend_pos = self._get_legend_position(loc)
776
+ fig.update_layout(
777
+ legend=dict(
778
+ **legend_pos,
779
+ bgcolor="rgba(255,255,255,0.9)",
780
+ bordercolor="black",
781
+ borderwidth=1,
782
+ )
783
+ )
784
+
637
785
  def finalize_layout(
638
786
  self,
639
787
  fig: go.Figure,
@@ -664,7 +812,7 @@ class PlotlyBackend:
664
812
  import numpy as np
665
813
 
666
814
  for row in fig._mb_format_rows:
667
- xaxis_name = f"xaxis{row}" if row > 1 else "xaxis"
815
+ xaxis_name = self._axis_name("xaxis", row)
668
816
  xaxis = getattr(fig.layout, xaxis_name, None)
669
817
 
670
818
  # Get x-range from the axis or compute from data
@@ -672,18 +820,16 @@ class PlotlyBackend:
672
820
  if xaxis and xaxis.range:
673
821
  x_range = xaxis.range
674
822
  else:
675
- # Compute from trace data
823
+ # Compute from trace data (filter out None values from legend traces)
676
824
  x_vals = []
677
825
  for trace in fig.data:
678
826
  if hasattr(trace, "x") and trace.x is not None:
679
- x_vals.extend(list(trace.x))
827
+ x_vals.extend([v for v in trace.x if v is not None])
680
828
  if x_vals:
681
829
  x_range = [min(x_vals), max(x_vals)]
682
830
 
683
831
  if x_range:
684
- # Create nice tick values in Mb
685
- x_min_mb = x_range[0] / 1e6
686
- x_max_mb = x_range[1] / 1e6
832
+ x_min_mb, x_max_mb = x_range[0] / 1e6, x_range[1] / 1e6
687
833
  span_mb = x_max_mb - x_min_mb
688
834
 
689
835
  # Choose tick spacing based on range
@@ -700,7 +846,9 @@ class PlotlyBackend:
700
846
 
701
847
  # Generate ticks
702
848
  first_tick = np.ceil(x_min_mb / tick_step) * tick_step
703
- tickvals_mb = np.arange(first_tick, x_max_mb + tick_step / 2, tick_step)
849
+ tickvals_mb = np.arange(
850
+ first_tick, x_max_mb + tick_step / 2, tick_step
851
+ )
704
852
  tickvals_bp = [v * 1e6 for v in tickvals_mb]
705
853
  ticktext = [f"{v:.2f}" for v in tickvals_mb]
706
854
 
pylocuszoom/colors.py CHANGED
@@ -236,4 +236,6 @@ def get_credible_set_color_palette(n_sets: int = 10) -> dict[int, str]:
236
236
  >>> palette[1]
237
237
  '#FF7F00'
238
238
  """
239
- return {i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)}
239
+ return {
240
+ i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)
241
+ }
pylocuszoom/gene_track.py CHANGED
@@ -362,6 +362,7 @@ def plot_gene_track_generic(
362
362
 
363
363
  backend.set_xlim(ax, start, end)
364
364
  backend.set_ylabel(ax, "", fontsize=10)
365
+ backend.hide_yaxis(ax)
365
366
 
366
367
  if region_genes.empty:
367
368
  backend.set_ylim(ax, 0, 1)