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.
- pylocuszoom/__init__.py +52 -1
- pylocuszoom/backends/base.py +45 -0
- pylocuszoom/backends/bokeh_backend.py +138 -48
- pylocuszoom/backends/matplotlib_backend.py +104 -0
- pylocuszoom/backends/plotly_backend.py +212 -64
- pylocuszoom/colors.py +3 -1
- pylocuszoom/gene_track.py +1 -0
- pylocuszoom/loaders.py +862 -0
- pylocuszoom/plotter.py +84 -113
- pylocuszoom/py.typed +0 -0
- pylocuszoom/recombination.py +4 -4
- pylocuszoom/schemas.py +395 -0
- {pylocuszoom-0.3.0.dist-info → pylocuszoom-0.5.0.dist-info}/METADATA +104 -24
- pylocuszoom-0.5.0.dist-info/RECORD +24 -0
- pylocuszoom-0.3.0.dist-info/RECORD +0 -21
- {pylocuszoom-0.3.0.dist-info → pylocuszoom-0.5.0.dist-info}/WHEEL +0 -0
- {pylocuszoom-0.3.0.dist-info → pylocuszoom-0.5.0.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -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
|
|
129
|
+
if col_lower in ("p-value", "pval", "p_value"):
|
|
116
130
|
hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
|
|
117
|
-
elif
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**{
|
|
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
|
-
**{
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
239
|
+
return {
|
|
240
|
+
i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)
|
|
241
|
+
}
|