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 CHANGED
@@ -34,7 +34,7 @@ Species Support:
34
34
  - Custom: User provides all reference data
35
35
  """
36
36
 
37
- __version__ = "0.1.0"
37
+ __version__ = "0.3.0"
38
38
 
39
39
  # Main plotter class
40
40
  # Backend types
@@ -74,6 +74,31 @@ from .labels import add_snp_labels
74
74
  # LD calculation
75
75
  from .ld import calculate_ld
76
76
 
77
+ # File format loaders
78
+ from .loaders import (
79
+ load_bed,
80
+ load_bolt_lmm,
81
+ load_caviar,
82
+ load_ensembl_genes,
83
+ load_eqtl_catalogue,
84
+ load_finemap,
85
+ load_gemma,
86
+ # eQTL loaders
87
+ load_gtex_eqtl,
88
+ # Gene annotation loaders
89
+ load_gtf,
90
+ # GWAS loaders
91
+ load_gwas,
92
+ load_gwas_catalog,
93
+ load_matrixeqtl,
94
+ load_plink_assoc,
95
+ load_polyfun,
96
+ load_regenie,
97
+ load_saige,
98
+ # Fine-mapping loaders
99
+ load_susie,
100
+ )
101
+
77
102
  # Logging configuration
78
103
  from .logging import disable_logging, enable_logging
79
104
  from .plotter import LocusZoomPlotter
@@ -86,6 +111,9 @@ from .recombination import (
86
111
  load_recombination_map,
87
112
  )
88
113
 
114
+ # Schema validation
115
+ from .schemas import LoaderValidationError
116
+
89
117
  # Validation utilities
90
118
  from .utils import ValidationError, to_pandas
91
119
 
@@ -136,4 +164,27 @@ __all__ = [
136
164
  # Validation & Utils
137
165
  "ValidationError",
138
166
  "to_pandas",
167
+ # GWAS loaders
168
+ "load_gwas",
169
+ "load_plink_assoc",
170
+ "load_regenie",
171
+ "load_bolt_lmm",
172
+ "load_gemma",
173
+ "load_saige",
174
+ "load_gwas_catalog",
175
+ # eQTL loaders
176
+ "load_gtex_eqtl",
177
+ "load_eqtl_catalogue",
178
+ "load_matrixeqtl",
179
+ # Fine-mapping loaders
180
+ "load_susie",
181
+ "load_finemap",
182
+ "load_caviar",
183
+ "load_polyfun",
184
+ # Gene annotation loaders
185
+ "load_gtf",
186
+ "load_bed",
187
+ "load_ensembl_genes",
188
+ # Schema validation
189
+ "LoaderValidationError",
139
190
  ]
@@ -341,3 +341,48 @@ class PlotBackend(Protocol):
341
341
  fig: Figure object.
342
342
  """
343
343
  ...
344
+
345
+ def add_eqtl_legend(
346
+ self,
347
+ ax: Any,
348
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
349
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
350
+ ) -> None:
351
+ """Add eQTL effect size legend to the axes.
352
+
353
+ Args:
354
+ ax: Axes or panel.
355
+ eqtl_positive_bins: List of (min, max, label, color) for positive effects.
356
+ eqtl_negative_bins: List of (min, max, label, color) for negative effects.
357
+ """
358
+ ...
359
+
360
+ def add_finemapping_legend(
361
+ self,
362
+ ax: Any,
363
+ credible_sets: List[int],
364
+ get_color_func: Any,
365
+ ) -> None:
366
+ """Add fine-mapping credible set legend to the axes.
367
+
368
+ Args:
369
+ ax: Axes or panel.
370
+ credible_sets: List of credible set IDs to show.
371
+ get_color_func: Function that takes CS ID and returns color.
372
+ """
373
+ ...
374
+
375
+ def add_simple_legend(
376
+ self,
377
+ ax: Any,
378
+ label: str,
379
+ loc: str = "upper right",
380
+ ) -> None:
381
+ """Add a simple legend entry for labeled data already in the plot.
382
+
383
+ Args:
384
+ ax: Axes or panel.
385
+ label: Legend label for labeled scatter data.
386
+ loc: Legend location.
387
+ """
388
+ ...
@@ -67,9 +67,13 @@ class BokehBackend:
67
67
  toolbar_location="above" if i == 0 else None,
68
68
  )
69
69
 
70
- # Style
71
- p.grid.grid_line_alpha = 0.3
70
+ # Style - no grid lines, black axes for clean LocusZoom appearance
71
+ p.grid.visible = False
72
72
  p.outline_line_color = None
73
+ p.xaxis.axis_line_color = "black"
74
+ p.yaxis.axis_line_color = "black"
75
+ p.xaxis.minor_tick_line_color = None
76
+ p.yaxis.minor_tick_line_color = None
73
77
 
74
78
  figures.append(p)
75
79
 
@@ -115,9 +119,9 @@ class BokehBackend:
115
119
  for col in hover_data.columns:
116
120
  data[col] = hover_data[col].values
117
121
  col_lower = col.lower()
118
- if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
122
+ if col_lower in ("p-value", "pval", "p_value"):
119
123
  tooltips.append((col, "@{" + col + "}{0.2e}"))
120
- elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
124
+ elif any(x in col_lower for x in ("r2", "r²", "ld")):
121
125
  tooltips.append((col, "@{" + col + "}{0.3f}"))
122
126
  elif "pos" in col_lower:
123
127
  tooltips.append((col, "@{" + col + "}{0,0}"))
@@ -351,6 +355,16 @@ class BokehBackend:
351
355
  ax.yaxis.axis_label = label
352
356
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
353
357
 
358
+ def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
359
+ """Map matplotlib-style legend location to Bokeh location."""
360
+ loc_map = {
361
+ "upper left": "top_left",
362
+ "upper right": "top_right",
363
+ "lower left": "bottom_left",
364
+ "lower right": "bottom_right",
365
+ }
366
+ return loc_map.get(loc, default)
367
+
354
368
  def _convert_label(self, label: str) -> str:
355
369
  """Convert LaTeX-style labels to Unicode for Bokeh display."""
356
370
  conversions = [
@@ -465,60 +479,90 @@ class BokehBackend:
465
479
  label = self._convert_label(label)
466
480
  # Find the secondary axis and update its label
467
481
  for renderer in ax.right:
468
- if hasattr(renderer, "y_range_name") and renderer.y_range_name == yaxis_name:
482
+ if (
483
+ hasattr(renderer, "y_range_name")
484
+ and renderer.y_range_name == yaxis_name
485
+ ):
469
486
  renderer.axis_label = label
470
487
  renderer.axis_label_text_font_size = f"{fontsize}pt"
471
488
  renderer.axis_label_text_color = color
472
489
  renderer.major_label_text_color = color
473
490
  break
474
491
 
475
- def add_ld_legend(
476
- self,
477
- ax: figure,
478
- ld_bins: List[Tuple[float, str, str]],
479
- lead_snp_color: str,
480
- ) -> None:
481
- """Add LD color legend using invisible dummy glyphs.
492
+ def _ensure_legend_range(self, ax: figure) -> Any:
493
+ """Ensure legend range exists and return a dummy data source.
482
494
 
483
- Creates legend entries with dummy renderers that are excluded from
484
- the data range calculation to avoid affecting axis scaling.
495
+ Creates a separate y-range for legend glyphs so they don't affect
496
+ the main plot's axis scaling.
485
497
  """
486
- from bokeh.models import ColumnDataSource, Legend, LegendItem, Range1d, Scatter
487
-
488
- legend_items = []
498
+ from bokeh.models import ColumnDataSource, Range1d
489
499
 
490
- # Create a separate range for legend glyphs that won't affect the main plot
491
500
  if "legend_range" not in ax.extra_y_ranges:
492
501
  ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
502
+ return ColumnDataSource(data={"x": [0], "y": [0]})
493
503
 
494
- # Use coordinates within the legend range
495
- dummy_source = ColumnDataSource(data={"x": [0], "y": [0]})
504
+ def _add_legend_item(
505
+ self,
506
+ ax: figure,
507
+ source: Any,
508
+ label: str,
509
+ color: str,
510
+ marker: str,
511
+ size: int = 10,
512
+ ) -> Any:
513
+ """Create an invisible scatter renderer for a legend entry."""
514
+ from bokeh.models import LegendItem
515
+
516
+ renderer = ax.scatter(
517
+ x="x",
518
+ y="y",
519
+ source=source,
520
+ marker=marker,
521
+ size=size,
522
+ fill_color=color,
523
+ line_color="black",
524
+ line_width=0.5,
525
+ y_range_name="legend_range",
526
+ visible=False,
527
+ )
528
+ return LegendItem(label=label, renderers=[renderer])
496
529
 
497
- # Add LD bin markers (no lead SNP - it's shown in the actual plot)
498
- for _, label, color in ld_bins:
499
- glyph = Scatter(
500
- x="x",
501
- y="y",
502
- marker="square",
503
- size=10,
504
- fill_color=color,
505
- line_color="black",
506
- line_width=0.5,
507
- )
508
- renderer = ax.add_glyph(dummy_source, glyph)
509
- renderer.y_range_name = "legend_range"
510
- renderer.visible = False
511
- legend_items.append(LegendItem(label=label, renderers=[renderer]))
530
+ def _create_legend(self, ax: figure, items: List[Any], title: str) -> None:
531
+ """Create and add a styled legend to the figure."""
532
+ from bokeh.models import Legend
512
533
 
513
534
  legend = Legend(
514
- items=legend_items,
535
+ items=items,
515
536
  location="top_right",
516
- title="r²",
537
+ title=title,
517
538
  background_fill_alpha=0.9,
518
539
  border_line_color="black",
540
+ spacing=0,
541
+ padding=4,
542
+ label_height=12,
543
+ glyph_height=12,
519
544
  )
520
545
  ax.add_layout(legend)
521
546
 
547
+ def add_ld_legend(
548
+ self,
549
+ ax: figure,
550
+ ld_bins: List[Tuple[float, str, str]],
551
+ lead_snp_color: str,
552
+ ) -> None:
553
+ """Add LD color legend using invisible dummy glyphs.
554
+
555
+ Creates legend entries with dummy renderers that are excluded from
556
+ the data range calculation to avoid affecting axis scaling.
557
+ """
558
+ source = self._ensure_legend_range(ax)
559
+ items = [
560
+ self._add_legend_item(ax, source, "Lead SNP", lead_snp_color, "diamond", 12)
561
+ ]
562
+ for _, label, color in ld_bins:
563
+ items.append(self._add_legend_item(ax, source, label, color, "square"))
564
+ self._create_legend(ax, items, "r²")
565
+
522
566
  def add_legend(
523
567
  self,
524
568
  ax: figure,
@@ -528,17 +572,7 @@ class BokehBackend:
528
572
  title: Optional[str] = None,
529
573
  ) -> Any:
530
574
  """Configure legend on the figure."""
531
- # Bokeh handles legend automatically from legend_label
532
- # Just configure position
533
-
534
- loc_map = {
535
- "upper left": "top_left",
536
- "upper right": "top_right",
537
- "lower left": "bottom_left",
538
- "lower right": "bottom_right",
539
- }
540
-
541
- ax.legend.location = loc_map.get(loc, "top_left")
575
+ ax.legend.location = self._get_legend_location(loc, "top_left")
542
576
  if title:
543
577
  ax.legend.title = title
544
578
  ax.legend.background_fill_alpha = 0.9
@@ -554,6 +588,11 @@ class BokehBackend:
554
588
  """
555
589
  pass
556
590
 
591
+ def hide_yaxis(self, ax: figure) -> None:
592
+ """Hide y-axis ticks, labels, line, and grid for gene track panels."""
593
+ ax.yaxis.visible = False
594
+ ax.ygrid.visible = False
595
+
557
596
  def format_xaxis_mb(self, ax: figure) -> None:
558
597
  """Format x-axis to show megabase values."""
559
598
  from bokeh.models import CustomJSTickFormatter
@@ -593,6 +632,57 @@ class BokehBackend:
593
632
  """Close the figure (no-op for bokeh)."""
594
633
  pass
595
634
 
635
+ def add_eqtl_legend(
636
+ self,
637
+ ax: figure,
638
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
639
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
640
+ ) -> None:
641
+ """Add eQTL effect size legend using invisible dummy glyphs."""
642
+ source = self._ensure_legend_range(ax)
643
+ items = []
644
+ for _, _, label, color in eqtl_positive_bins:
645
+ items.append(self._add_legend_item(ax, source, label, color, "triangle"))
646
+ for _, _, label, color in eqtl_negative_bins:
647
+ items.append(
648
+ self._add_legend_item(ax, source, label, color, "inverted_triangle")
649
+ )
650
+ self._create_legend(ax, items, "eQTL effect")
651
+
652
+ def add_finemapping_legend(
653
+ self,
654
+ ax: figure,
655
+ credible_sets: List[int],
656
+ get_color_func: Any,
657
+ ) -> None:
658
+ """Add fine-mapping credible set legend using invisible dummy glyphs."""
659
+ if not credible_sets:
660
+ return
661
+
662
+ source = self._ensure_legend_range(ax)
663
+ items = [
664
+ self._add_legend_item(
665
+ ax, source, f"CS{cs_id}", get_color_func(cs_id), "circle"
666
+ )
667
+ for cs_id in credible_sets
668
+ ]
669
+ self._create_legend(ax, items, "Credible sets")
670
+
671
+ def add_simple_legend(
672
+ self,
673
+ ax: figure,
674
+ label: str,
675
+ loc: str = "upper right",
676
+ ) -> None:
677
+ """Configure legend position.
678
+
679
+ Bokeh handles legends automatically from legend_label.
680
+ This just positions the legend.
681
+ """
682
+ ax.legend.location = self._get_legend_location(loc, "top_right")
683
+ ax.legend.background_fill_alpha = 0.9
684
+ ax.legend.border_line_color = "black"
685
+
596
686
  def finalize_layout(
597
687
  self,
598
688
  fig: Any,
@@ -290,6 +290,110 @@ class MatplotlibBackend:
290
290
  """Close the figure and free resources."""
291
291
  plt.close(fig)
292
292
 
293
+ def add_eqtl_legend(
294
+ self,
295
+ ax: Axes,
296
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
297
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
298
+ ) -> None:
299
+ """Add eQTL effect size legend using matplotlib Line2D markers."""
300
+ from matplotlib.lines import Line2D
301
+
302
+ legend_elements = []
303
+
304
+ # Positive effects (upward triangles)
305
+ for _, _, label, color in eqtl_positive_bins:
306
+ legend_elements.append(
307
+ Line2D(
308
+ [0],
309
+ [0],
310
+ marker="^",
311
+ color="w",
312
+ markerfacecolor=color,
313
+ markeredgecolor="black",
314
+ markersize=7,
315
+ label=label,
316
+ )
317
+ )
318
+
319
+ # Negative effects (downward triangles)
320
+ for _, _, label, color in eqtl_negative_bins:
321
+ legend_elements.append(
322
+ Line2D(
323
+ [0],
324
+ [0],
325
+ marker="v",
326
+ color="w",
327
+ markerfacecolor=color,
328
+ markeredgecolor="black",
329
+ markersize=7,
330
+ label=label,
331
+ )
332
+ )
333
+
334
+ ax.legend(
335
+ handles=legend_elements,
336
+ loc="upper right",
337
+ fontsize=8,
338
+ frameon=True,
339
+ framealpha=0.9,
340
+ title="eQTL effect",
341
+ title_fontsize=9,
342
+ handlelength=1.2,
343
+ handleheight=1.0,
344
+ labelspacing=0.3,
345
+ )
346
+
347
+ def add_finemapping_legend(
348
+ self,
349
+ ax: Axes,
350
+ credible_sets: List[int],
351
+ get_color_func: Any,
352
+ ) -> None:
353
+ """Add fine-mapping credible set legend using matplotlib Line2D markers."""
354
+ from matplotlib.lines import Line2D
355
+
356
+ if not credible_sets:
357
+ return
358
+
359
+ legend_elements = []
360
+ for cs_id in credible_sets:
361
+ color = get_color_func(cs_id)
362
+ legend_elements.append(
363
+ Line2D(
364
+ [0],
365
+ [0],
366
+ marker="o",
367
+ color="w",
368
+ markerfacecolor=color,
369
+ markeredgecolor="black",
370
+ markersize=7,
371
+ label=f"CS{cs_id}",
372
+ )
373
+ )
374
+
375
+ ax.legend(
376
+ handles=legend_elements,
377
+ loc="upper right",
378
+ fontsize=8,
379
+ frameon=True,
380
+ framealpha=0.9,
381
+ title="Credible sets",
382
+ title_fontsize=9,
383
+ handlelength=1.2,
384
+ handleheight=1.0,
385
+ labelspacing=0.3,
386
+ )
387
+
388
+ def add_simple_legend(
389
+ self,
390
+ ax: Axes,
391
+ label: str,
392
+ loc: str = "upper right",
393
+ ) -> None:
394
+ """Add simple legend for labeled scatter data."""
395
+ ax.legend(loc=loc, fontsize=9)
396
+
293
397
  def finalize_layout(
294
398
  self,
295
399
  fig: Figure,