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/plotter.py CHANGED
@@ -139,11 +139,8 @@ class LocusZoomPlotter:
139
139
  @staticmethod
140
140
  def _default_build(species: str) -> Optional[str]:
141
141
  """Get default genome build for species."""
142
- if species == "canine":
143
- return "canfam3.1"
144
- if species == "feline":
145
- return "felCat9"
146
- return None
142
+ builds = {"canine": "canfam3.1", "feline": "felCat9"}
143
+ return builds.get(species)
147
144
 
148
145
  def _ensure_recomb_maps(self) -> Optional[Path]:
149
146
  """Ensure recombination maps are downloaded.
@@ -157,8 +154,8 @@ class LocusZoomPlotter:
157
154
  default_dir = get_default_data_dir()
158
155
  if (
159
156
  default_dir.exists()
160
- and len(list(default_dir.glob("chr*_recomb.tsv"))) >= 38
161
- ):
157
+ and len(list(default_dir.glob("chr*_recomb.tsv"))) >= 39
158
+ ): # 38 autosomes + X
162
159
  return default_dir
163
160
  # Download
164
161
  try:
@@ -215,7 +212,7 @@ class LocusZoomPlotter:
215
212
  p_col: str = "p_wald",
216
213
  rs_col: str = "rs",
217
214
  figsize: Tuple[int, int] = (12, 8),
218
- ) -> Figure:
215
+ ) -> Any:
219
216
  """Create a regional association plot.
220
217
 
221
218
  Args:
@@ -589,53 +586,6 @@ class LocusZoomPlotter:
589
586
  yaxis_name=secondary_y,
590
587
  )
591
588
 
592
- def _add_eqtl_legend(self, ax: Axes) -> None:
593
- """Add eQTL effect size legend to plot."""
594
- legend_elements = []
595
-
596
- # Positive effects (upward triangles)
597
- for _, _, label, color in EQTL_POSITIVE_BINS:
598
- legend_elements.append(
599
- Line2D(
600
- [0],
601
- [0],
602
- marker="^",
603
- color="w",
604
- markerfacecolor=color,
605
- markeredgecolor="black",
606
- markersize=7,
607
- label=label,
608
- )
609
- )
610
-
611
- # Negative effects (downward triangles)
612
- for _, _, label, color in EQTL_NEGATIVE_BINS:
613
- legend_elements.append(
614
- Line2D(
615
- [0],
616
- [0],
617
- marker="v",
618
- color="w",
619
- markerfacecolor=color,
620
- markeredgecolor="black",
621
- markersize=7,
622
- label=label,
623
- )
624
- )
625
-
626
- ax.legend(
627
- handles=legend_elements,
628
- loc="upper right",
629
- fontsize=8,
630
- frameon=True,
631
- framealpha=0.9,
632
- title="eQTL effect",
633
- title_fontsize=9,
634
- handlelength=1.2,
635
- handleheight=1.0,
636
- labelspacing=0.3,
637
- )
638
-
639
589
  def _plot_finemapping(
640
590
  self,
641
591
  ax: Axes,
@@ -657,6 +607,23 @@ class LocusZoomPlotter:
657
607
  show_credible_sets: Whether to color points by credible set.
658
608
  pip_threshold: Minimum PIP to display as scatter point.
659
609
  """
610
+
611
+ def _build_finemapping_hover_data(
612
+ subset_df: pd.DataFrame,
613
+ ) -> Optional[pd.DataFrame]:
614
+ """Build hover data for interactive backends."""
615
+ hover_cols = {}
616
+ # Position
617
+ if pos_col in subset_df.columns:
618
+ hover_cols["Position"] = subset_df[pos_col].values
619
+ # PIP
620
+ if pip_col in subset_df.columns:
621
+ hover_cols["PIP"] = subset_df[pip_col].values
622
+ # Credible set
623
+ if cs_col and cs_col in subset_df.columns:
624
+ hover_cols["Credible Set"] = subset_df[cs_col].values
625
+ return pd.DataFrame(hover_cols) if hover_cols else None
626
+
660
627
  # Sort by position for line plotting
661
628
  df = df.sort_values(pos_col)
662
629
 
@@ -690,7 +657,7 @@ class LocusZoomPlotter:
690
657
  edgecolor="black",
691
658
  linewidth=0.5,
692
659
  zorder=3,
693
- label=f"CS{cs_id}",
660
+ hover_data=_build_finemapping_hover_data(cs_data),
694
661
  )
695
662
  # Plot variants not in any credible set
696
663
  non_cs_data = df[(df[cs_col].isna()) | (df[cs_col] == 0)]
@@ -707,6 +674,7 @@ class LocusZoomPlotter:
707
674
  edgecolor="black",
708
675
  linewidth=0.3,
709
676
  zorder=2,
677
+ hover_data=_build_finemapping_hover_data(non_cs_data),
710
678
  )
711
679
  else:
712
680
  # No credible sets - show all points above threshold
@@ -723,51 +691,9 @@ class LocusZoomPlotter:
723
691
  edgecolor="black",
724
692
  linewidth=0.5,
725
693
  zorder=3,
694
+ hover_data=_build_finemapping_hover_data(high_pip),
726
695
  )
727
696
 
728
- def _add_finemapping_legend(
729
- self,
730
- ax: Axes,
731
- credible_sets: List[int],
732
- ) -> None:
733
- """Add fine-mapping legend showing credible sets.
734
-
735
- Args:
736
- ax: Matplotlib axes object.
737
- credible_sets: List of credible set IDs to include.
738
- """
739
- if not credible_sets:
740
- return
741
-
742
- legend_elements = []
743
- for cs_id in credible_sets:
744
- color = get_credible_set_color(cs_id)
745
- legend_elements.append(
746
- Line2D(
747
- [0],
748
- [0],
749
- marker="o",
750
- color="w",
751
- markerfacecolor=color,
752
- markeredgecolor="black",
753
- markersize=7,
754
- label=f"CS{cs_id}",
755
- )
756
- )
757
-
758
- ax.legend(
759
- handles=legend_elements,
760
- loc="upper right",
761
- fontsize=8,
762
- frameon=True,
763
- framealpha=0.9,
764
- title="Credible sets",
765
- title_fontsize=9,
766
- handlelength=1.2,
767
- handleheight=1.0,
768
- labelspacing=0.3,
769
- )
770
-
771
697
  def plot_stacked(
772
698
  self,
773
699
  gwas_dfs: List[pd.DataFrame],
@@ -968,7 +894,9 @@ class LocusZoomPlotter:
968
894
  panel_ld_col = "R2"
969
895
 
970
896
  # Plot association
971
- self._plot_association(ax, df, pos_col, panel_ld_col, lead_pos, rs_col, p_col)
897
+ self._plot_association(
898
+ ax, df, pos_col, panel_ld_col, lead_pos, rs_col, p_col
899
+ )
972
900
 
973
901
  # Add significance line
974
902
  self._backend.axhline(
@@ -1080,10 +1008,12 @@ class LocusZoomPlotter:
1080
1008
  pip_threshold=0.01,
1081
1009
  )
1082
1010
 
1083
- # Add legend for credible sets
1011
+ # Add legend for credible sets (all backends)
1084
1012
  credible_sets = get_credible_sets(fm_data, finemapping_cs_col)
1085
1013
  if credible_sets:
1086
- self._add_finemapping_legend(ax, credible_sets)
1014
+ self._backend.add_finemapping_legend(
1015
+ ax, credible_sets, get_credible_set_color
1016
+ )
1087
1017
 
1088
1018
  self._backend.set_ylabel(ax, "PIP")
1089
1019
  self._backend.set_ylim(ax, -0.05, 1.05)
@@ -1111,30 +1041,70 @@ class LocusZoomPlotter:
1111
1041
  eqtl_data["p_value"].clip(lower=1e-300)
1112
1042
  )
1113
1043
 
1044
+ def _build_eqtl_hover_data(
1045
+ subset_df: pd.DataFrame,
1046
+ ) -> Optional[pd.DataFrame]:
1047
+ """Build hover data for eQTL interactive backends."""
1048
+ hover_cols = {}
1049
+ # Position
1050
+ if "pos" in subset_df.columns:
1051
+ hover_cols["Position"] = subset_df["pos"].values
1052
+ # P-value
1053
+ if "p_value" in subset_df.columns:
1054
+ hover_cols["P-value"] = subset_df["p_value"].values
1055
+ # Effect size
1056
+ if "effect_size" in subset_df.columns:
1057
+ hover_cols["Effect"] = subset_df["effect_size"].values
1058
+ # Gene
1059
+ if "gene" in subset_df.columns:
1060
+ hover_cols["Gene"] = subset_df["gene"].values
1061
+ return pd.DataFrame(hover_cols) if hover_cols else None
1062
+
1114
1063
  # Check if effect_size column exists for directional coloring
1115
1064
  has_effect = "effect_size" in eqtl_data.columns
1116
1065
 
1117
1066
  if has_effect:
1118
- # Plot triangles by effect direction with color by magnitude
1119
- for _, row in eqtl_data.iterrows():
1120
- effect = row["effect_size"]
1121
- color = get_eqtl_color(effect)
1122
- marker = "^" if effect >= 0 else "v"
1067
+ # Plot triangles by effect direction (batch by sign for efficiency)
1068
+ pos_effects = eqtl_data[eqtl_data["effect_size"] >= 0]
1069
+ neg_effects = eqtl_data[eqtl_data["effect_size"] < 0]
1070
+
1071
+ # Plot positive effects (up triangles)
1072
+ for _, row in pos_effects.iterrows():
1073
+ row_df = pd.DataFrame([row])
1074
+ self._backend.scatter(
1075
+ ax,
1076
+ pd.Series([row["pos"]]),
1077
+ pd.Series([row["neglog10p"]]),
1078
+ colors=get_eqtl_color(row["effect_size"]),
1079
+ sizes=50,
1080
+ marker="^",
1081
+ edgecolor="black",
1082
+ linewidth=0.5,
1083
+ zorder=2,
1084
+ hover_data=_build_eqtl_hover_data(row_df),
1085
+ )
1086
+ # Plot negative effects (down triangles)
1087
+ for _, row in neg_effects.iterrows():
1088
+ row_df = pd.DataFrame([row])
1123
1089
  self._backend.scatter(
1124
1090
  ax,
1125
1091
  pd.Series([row["pos"]]),
1126
1092
  pd.Series([row["neglog10p"]]),
1127
- colors=color,
1093
+ colors=get_eqtl_color(row["effect_size"]),
1128
1094
  sizes=50,
1129
- marker=marker,
1095
+ marker="v",
1130
1096
  edgecolor="black",
1131
1097
  linewidth=0.5,
1132
1098
  zorder=2,
1099
+ hover_data=_build_eqtl_hover_data(row_df),
1133
1100
  )
1134
- # Add eQTL effect legend
1135
- self._add_eqtl_legend(ax)
1101
+ # Add eQTL effect legend (all backends)
1102
+ self._backend.add_eqtl_legend(
1103
+ ax, EQTL_POSITIVE_BINS, EQTL_NEGATIVE_BINS
1104
+ )
1136
1105
  else:
1137
1106
  # No effect sizes - plot as diamonds
1107
+ label = f"eQTL ({eqtl_gene})" if eqtl_gene else "eQTL"
1138
1108
  self._backend.scatter(
1139
1109
  ax,
1140
1110
  eqtl_data["pos"],
@@ -1145,9 +1115,10 @@ class LocusZoomPlotter:
1145
1115
  edgecolor="black",
1146
1116
  linewidth=0.5,
1147
1117
  zorder=2,
1148
- label=f"eQTL ({eqtl_gene})" if eqtl_gene else "eQTL",
1118
+ label=label,
1119
+ hover_data=_build_eqtl_hover_data(eqtl_data),
1149
1120
  )
1150
- ax.legend(loc="upper right", fontsize=9)
1121
+ self._backend.add_simple_legend(ax, label, loc="upper right")
1151
1122
 
1152
1123
  self._backend.set_ylabel(ax, r"$-\log_{10}$ P (eQTL)")
1153
1124
  self._backend.axhline(
pylocuszoom/py.typed ADDED
File without changes
@@ -42,7 +42,7 @@ def _normalize_build(build: Optional[str]) -> Optional[str]:
42
42
  if build is None:
43
43
  return None
44
44
  build_lower = build.lower().replace(".", "").replace("_", "")
45
- if "canfam4" in build_lower or "uucfamgsd" in build_lower:
45
+ if any(x in build_lower for x in ("canfam4", "uucfamgsd")):
46
46
  return "canfam4"
47
47
  if "canfam3" in build_lower:
48
48
  return "canfam3"
@@ -158,9 +158,9 @@ def get_default_data_dir() -> Path:
158
158
  """Get default directory for recombination map data.
159
159
 
160
160
  Returns platform-appropriate cache directory:
161
- - macOS: ~/Library/Caches/snp-scope-plot
162
- - Linux: ~/.cache/snp-scope-plot
161
+ - macOS/Linux: ~/.cache/snp-scope-plot (or $XDG_CACHE_HOME if set)
163
162
  - Windows: %LOCALAPPDATA%/snp-scope-plot
163
+ - Databricks: /dbfs/FileStore/reference_data/recombination_maps
164
164
  """
165
165
  if os.name == "nt": # Windows
166
166
  base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
@@ -207,7 +207,7 @@ def download_canine_recombination_maps(
207
207
  # Check if already downloaded
208
208
  if output_path.exists() and not force:
209
209
  existing_files = list(output_path.glob("chr*_recomb.tsv"))
210
- if len(existing_files) >= 38: # 38 autosomes + X
210
+ if len(existing_files) >= 39: # 38 autosomes + X
211
211
  return output_path
212
212
 
213
213
  # Create output directory