pylocuszoom 0.2.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,8 +71,23 @@ class PlotlyBackend:
71
71
  template="plotly_white",
72
72
  )
73
73
 
74
- # Return row indices (1-based for plotly)
75
- panel_refs = list(range(1, n_panels + 1))
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
+
88
+ # Return (fig, row) tuples for each panel
89
+ # This matches the expected ax parameter format for all methods
90
+ panel_refs = [(fig, row) for row in range(1, n_panels + 1)]
76
91
  return fig, panel_refs
77
92
 
78
93
  def scatter(
@@ -110,10 +125,13 @@ class PlotlyBackend:
110
125
  hover_cols = hover_data.columns.tolist()
111
126
  hovertemplate = "<b>%{customdata[0]}</b><br>"
112
127
  for i, col in enumerate(hover_cols[1:], 1):
113
- if "p" in col.lower():
128
+ col_lower = col.lower()
129
+ if col_lower in ("p-value", "pval", "p_value"):
114
130
  hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
115
- elif "r2" in col.lower() or "ld" in col.lower():
131
+ elif any(x in col_lower for x in ("r2", "r²", "ld")):
116
132
  hovertemplate += f"{col}: %{{customdata[{i}]:.3f}}<br>"
133
+ elif "pos" in col_lower:
134
+ hovertemplate += f"{col}: %{{customdata[{i}]:,.0f}}<br>"
117
135
  else:
118
136
  hovertemplate += f"{col}: %{{customdata[{i}]}}<br>"
119
137
  hovertemplate += "<extra></extra>"
@@ -221,6 +239,7 @@ class PlotlyBackend:
221
239
  color: str = "grey",
222
240
  linestyle: str = "--",
223
241
  linewidth: float = 1.0,
242
+ alpha: float = 1.0,
224
243
  zorder: int = 1,
225
244
  ) -> Any:
226
245
  """Add a horizontal line across the panel."""
@@ -234,6 +253,7 @@ class PlotlyBackend:
234
253
  line_dash=dash,
235
254
  line_color=color,
236
255
  line_width=linewidth,
256
+ opacity=alpha,
237
257
  row=row,
238
258
  col=1,
239
259
  )
@@ -299,26 +319,55 @@ class PlotlyBackend:
299
319
  col=1,
300
320
  )
301
321
 
322
+ def add_polygon(
323
+ self,
324
+ ax: Tuple[go.Figure, int],
325
+ points: List[List[float]],
326
+ facecolor: str = "blue",
327
+ edgecolor: str = "black",
328
+ linewidth: float = 0.5,
329
+ zorder: int = 2,
330
+ ) -> Any:
331
+ """Add a polygon (e.g., triangle for strand arrows) to the panel."""
332
+ fig, row = ax
333
+
334
+ # Build SVG path from points
335
+ path = f"M {points[0][0]} {points[0][1]}"
336
+ for px, py in points[1:]:
337
+ path += f" L {px} {py}"
338
+ path += " Z"
339
+
340
+ fig.add_shape(
341
+ type="path",
342
+ path=path,
343
+ fillcolor=facecolor,
344
+ line=dict(color=edgecolor, width=linewidth),
345
+ row=row,
346
+ col=1,
347
+ )
348
+
302
349
  def set_xlim(self, ax: Tuple[go.Figure, int], left: float, right: float) -> None:
303
350
  """Set x-axis limits."""
304
351
  fig, row = ax
305
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
306
- fig.update_layout(**{xaxis: dict(range=[left, right])})
352
+ fig.update_layout(**{self._axis_name("xaxis", row): dict(range=[left, right])})
307
353
 
308
354
  def set_ylim(self, ax: Tuple[go.Figure, int], bottom: float, top: float) -> None:
309
355
  """Set y-axis limits."""
310
356
  fig, row = ax
311
- yaxis = f"yaxis{row}" if row > 1 else "yaxis"
312
- fig.update_layout(**{yaxis: dict(range=[bottom, top])})
357
+ fig.update_layout(**{self._axis_name("yaxis", row): dict(range=[bottom, top])})
313
358
 
314
359
  def set_xlabel(
315
360
  self, ax: Tuple[go.Figure, int], label: str, fontsize: int = 12
316
361
  ) -> None:
317
362
  """Set x-axis label."""
318
363
  fig, row = ax
319
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
364
+ label = self._convert_label(label)
320
365
  fig.update_layout(
321
- **{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
+ }
322
371
  )
323
372
 
324
373
  def set_ylabel(
@@ -326,11 +375,49 @@ class PlotlyBackend:
326
375
  ) -> None:
327
376
  """Set y-axis label."""
328
377
  fig, row = ax
329
- yaxis = f"yaxis{row}" if row > 1 else "yaxis"
378
+ label = self._convert_label(label)
330
379
  fig.update_layout(
331
- **{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
+ }
332
385
  )
333
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
+
405
+ def _convert_label(self, label: str) -> str:
406
+ """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
420
+
334
421
  def set_title(
335
422
  self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
336
423
  ) -> None:
@@ -360,6 +447,189 @@ class PlotlyBackend:
360
447
 
361
448
  return (fig, row, secondary_y)
362
449
 
450
+ def line_secondary(
451
+ self,
452
+ ax: Tuple[go.Figure, int],
453
+ x: pd.Series,
454
+ y: pd.Series,
455
+ color: str = "blue",
456
+ linewidth: float = 1.5,
457
+ alpha: float = 1.0,
458
+ linestyle: str = "-",
459
+ label: Optional[str] = None,
460
+ yaxis_name: str = "y2",
461
+ ) -> Any:
462
+ """Create a line plot on secondary y-axis."""
463
+ fig, row = ax
464
+
465
+ dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
466
+ dash = dash_map.get(linestyle, "solid")
467
+
468
+ trace = go.Scatter(
469
+ x=x,
470
+ y=y,
471
+ mode="lines",
472
+ line=dict(color=color, width=linewidth, dash=dash),
473
+ opacity=alpha,
474
+ name=label or "",
475
+ showlegend=label is not None,
476
+ yaxis=yaxis_name,
477
+ hoverinfo="skip",
478
+ )
479
+
480
+ fig.add_trace(trace, row=row, col=1)
481
+ return trace
482
+
483
+ def fill_between_secondary(
484
+ self,
485
+ ax: Tuple[go.Figure, int],
486
+ x: pd.Series,
487
+ y1: Union[float, pd.Series],
488
+ y2: Union[float, pd.Series],
489
+ color: str = "blue",
490
+ alpha: float = 0.3,
491
+ yaxis_name: str = "y2",
492
+ ) -> Any:
493
+ """Fill area between two y-values on secondary y-axis."""
494
+ fig, row = ax
495
+
496
+ if isinstance(y1, (int, float)):
497
+ y1 = pd.Series([y1] * len(x))
498
+
499
+ trace = go.Scatter(
500
+ x=pd.concat([x, x[::-1]]),
501
+ y=pd.concat([y2, y1[::-1]]),
502
+ fill="toself",
503
+ fillcolor=color,
504
+ opacity=alpha,
505
+ line=dict(width=0),
506
+ showlegend=False,
507
+ hoverinfo="skip",
508
+ yaxis=yaxis_name,
509
+ )
510
+
511
+ fig.add_trace(trace, row=row, col=1)
512
+ return trace
513
+
514
+ def set_secondary_ylim(
515
+ self,
516
+ ax: Tuple[go.Figure, int],
517
+ bottom: float,
518
+ top: float,
519
+ yaxis_name: str = "y2",
520
+ ) -> None:
521
+ """Set secondary y-axis limits."""
522
+ fig, row = ax
523
+ yaxis_key = (
524
+ "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
525
+ )
526
+ fig.update_layout(**{yaxis_key: dict(range=[bottom, top])})
527
+
528
+ def set_secondary_ylabel(
529
+ self,
530
+ ax: Tuple[go.Figure, int],
531
+ label: str,
532
+ color: str = "black",
533
+ fontsize: int = 10,
534
+ yaxis_name: str = "y2",
535
+ ) -> None:
536
+ """Set secondary y-axis label."""
537
+ fig, row = ax
538
+ label = self._convert_label(label)
539
+ yaxis_key = (
540
+ "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
541
+ )
542
+ fig.update_layout(
543
+ **{
544
+ yaxis_key: dict(
545
+ title=dict(text=label, font=dict(size=fontsize, color=color)),
546
+ tickfont=dict(color=color),
547
+ )
548
+ }
549
+ )
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
+
612
+ def add_ld_legend(
613
+ self,
614
+ ax: Tuple[go.Figure, int],
615
+ ld_bins: List[Tuple[float, str, str]],
616
+ lead_snp_color: str,
617
+ ) -> None:
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
+ """
623
+ fig, row = ax
624
+
625
+ self._add_legend_item(
626
+ fig, row, "Lead SNP", lead_snp_color, "diamond", 12, "legend"
627
+ )
628
+ for _, label, color in ld_bins:
629
+ self._add_legend_item(fig, row, label, color, "square", 10, "legend")
630
+
631
+ self._configure_legend(fig, row, "legend", "r²")
632
+
363
633
  def add_legend(
364
634
  self,
365
635
  ax: Tuple[go.Figure, int],
@@ -374,16 +644,7 @@ class PlotlyBackend:
374
644
  This method updates legend positioning.
375
645
  """
376
646
  fig, _ = ax
377
-
378
- # Map matplotlib locations to plotly
379
- loc_map = {
380
- "upper left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
381
- "upper right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
382
- "lower left": dict(x=0.01, y=0.01, xanchor="left", yanchor="bottom"),
383
- "lower right": dict(x=0.99, y=0.01, xanchor="right", yanchor="bottom"),
384
- }
385
-
386
- legend_pos = loc_map.get(loc, loc_map["upper left"])
647
+ legend_pos = self._get_legend_position(loc)
387
648
  fig.update_layout(
388
649
  legend=dict(
389
650
  **legend_pos,
@@ -403,23 +664,30 @@ class PlotlyBackend:
403
664
  # No action needed - method exists for API compatibility
404
665
  pass
405
666
 
406
- def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
407
- """Format x-axis to show megabase values."""
667
+ def hide_yaxis(self, ax: Tuple[go.Figure, int]) -> None:
668
+ """Hide y-axis ticks, labels, line, and grid for gene track panels."""
408
669
  fig, row = ax
409
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
410
-
411
670
  fig.update_layout(
412
671
  **{
413
- xaxis: dict(
414
- tickformat=".2f",
415
- ticksuffix=" Mb",
416
- tickvals=None, # Auto
672
+ self._axis_name("yaxis", row): dict(
673
+ showticklabels=False,
674
+ showline=False,
675
+ showgrid=False,
676
+ ticks="",
417
677
  )
418
678
  }
419
679
  )
420
680
 
421
- # Apply custom tick formatting via ticktext/tickvals if needed
422
- # For now, let plotly auto-format
681
+ def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
682
+ """Format x-axis to show megabase values.
683
+
684
+ Stores the row for later tick formatting in finalize_layout.
685
+ """
686
+ fig, row = ax
687
+ # Store that this axis needs Mb formatting
688
+ if not hasattr(fig, "_mb_format_rows"):
689
+ fig._mb_format_rows = []
690
+ fig._mb_format_rows.append(row)
423
691
 
424
692
  def save(
425
693
  self,
@@ -447,6 +715,73 @@ class PlotlyBackend:
447
715
  """Close the figure (no-op for plotly)."""
448
716
  pass
449
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
+
450
785
  def finalize_layout(
451
786
  self,
452
787
  fig: go.Figure,
@@ -456,7 +791,7 @@ class PlotlyBackend:
456
791
  bottom: float = 0.1,
457
792
  hspace: float = 0.08,
458
793
  ) -> None:
459
- """Adjust layout margins.
794
+ """Adjust layout margins and apply Mb tick formatting.
460
795
 
461
796
  Args:
462
797
  fig: Figure object.
@@ -471,3 +806,58 @@ class PlotlyBackend:
471
806
  b=int(bottom * fig.layout.height) if fig.layout.height else 80,
472
807
  )
473
808
  )
809
+
810
+ # Apply Mb tick formatting to marked axes
811
+ if hasattr(fig, "_mb_format_rows"):
812
+ import numpy as np
813
+
814
+ for row in fig._mb_format_rows:
815
+ xaxis_name = self._axis_name("xaxis", row)
816
+ xaxis = getattr(fig.layout, xaxis_name, None)
817
+
818
+ # Get x-range from the axis or compute from data
819
+ x_range = None
820
+ if xaxis and xaxis.range:
821
+ x_range = xaxis.range
822
+ else:
823
+ # Compute from trace data (filter out None values from legend traces)
824
+ x_vals = []
825
+ for trace in fig.data:
826
+ if hasattr(trace, "x") and trace.x is not None:
827
+ x_vals.extend([v for v in trace.x if v is not None])
828
+ if x_vals:
829
+ x_range = [min(x_vals), max(x_vals)]
830
+
831
+ if x_range:
832
+ x_min_mb, x_max_mb = x_range[0] / 1e6, x_range[1] / 1e6
833
+ span_mb = x_max_mb - x_min_mb
834
+
835
+ # Choose tick spacing based on range
836
+ if span_mb <= 0.5:
837
+ tick_step = 0.1
838
+ elif span_mb <= 2:
839
+ tick_step = 0.25
840
+ elif span_mb <= 5:
841
+ tick_step = 0.5
842
+ elif span_mb <= 20:
843
+ tick_step = 2
844
+ else:
845
+ tick_step = 5
846
+
847
+ # Generate ticks
848
+ first_tick = np.ceil(x_min_mb / tick_step) * tick_step
849
+ tickvals_mb = np.arange(
850
+ first_tick, x_max_mb + tick_step / 2, tick_step
851
+ )
852
+ tickvals_bp = [v * 1e6 for v in tickvals_mb]
853
+ ticktext = [f"{v:.2f}" for v in tickvals_mb]
854
+
855
+ fig.update_layout(
856
+ **{
857
+ xaxis_name: dict(
858
+ tickvals=tickvals_bp,
859
+ ticktext=ticktext,
860
+ ticksuffix=" Mb",
861
+ )
862
+ }
863
+ )
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
+ }
@@ -6,7 +6,6 @@ fine-mapping results (SuSiE, FINEMAP, etc.) for visualization.
6
6
 
7
7
  from typing import List, Optional
8
8
 
9
- import numpy as np
10
9
  import pandas as pd
11
10
 
12
11
  from .logging import logger