pylocuszoom 0.6.0__py3-none-any.whl → 1.0.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.
@@ -3,7 +3,7 @@
3
3
  Defines the interface that matplotlib, plotly, and bokeh backends must implement.
4
4
  """
5
5
 
6
- from typing import Any, List, Optional, Protocol, Tuple, Union
6
+ from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
7
7
 
8
8
  import pandas as pd
9
9
 
@@ -13,8 +13,48 @@ class PlotBackend(Protocol):
13
13
 
14
14
  All backends (matplotlib, plotly, bokeh) must implement these methods
15
15
  to enable consistent plotting across different rendering engines.
16
+
17
+ Capability Properties:
18
+ supports_snp_labels: Whether backend supports text labels via adjustText.
19
+ supports_hover: Whether backend supports hover tooltips.
20
+ supports_secondary_axis: Whether backend supports twin y-axis for overlays.
16
21
  """
17
22
 
23
+ # =========================================================================
24
+ # Capability Properties
25
+ # =========================================================================
26
+
27
+ @property
28
+ def supports_snp_labels(self) -> bool:
29
+ """Whether backend supports text labels via adjustText.
30
+
31
+ Matplotlib supports SNP labels using adjustText for automatic repositioning.
32
+ Interactive backends (Plotly, Bokeh) use hover tooltips instead.
33
+ """
34
+ ...
35
+
36
+ @property
37
+ def supports_hover(self) -> bool:
38
+ """Whether backend supports hover tooltips.
39
+
40
+ Interactive backends (Plotly, Bokeh) support hover tooltips.
41
+ Matplotlib does not support hover - use SNP labels instead.
42
+ """
43
+ ...
44
+
45
+ @property
46
+ def supports_secondary_axis(self) -> bool:
47
+ """Whether backend supports twin y-axis for recombination overlay.
48
+
49
+ All current backends support secondary axes, but this allows for
50
+ future backends that may not.
51
+ """
52
+ ...
53
+
54
+ # =========================================================================
55
+ # Figure Creation
56
+ # =========================================================================
57
+
18
58
  def create_figure(
19
59
  self,
20
60
  n_panels: int,
@@ -35,6 +75,31 @@ class PlotBackend(Protocol):
35
75
  """
36
76
  ...
37
77
 
78
+ def finalize_layout(
79
+ self,
80
+ fig: Any,
81
+ left: float = 0.08,
82
+ right: float = 0.95,
83
+ top: float = 0.95,
84
+ bottom: float = 0.1,
85
+ hspace: float = 0.08,
86
+ ) -> None:
87
+ """Finalize figure layout with margins and spacing.
88
+
89
+ Args:
90
+ fig: Figure object.
91
+ left: Left margin fraction.
92
+ right: Right margin fraction.
93
+ top: Top margin fraction.
94
+ bottom: Bottom margin fraction.
95
+ hspace: Vertical space between subplots.
96
+ """
97
+ ...
98
+
99
+ # =========================================================================
100
+ # Basic Plotting
101
+ # =========================================================================
102
+
38
103
  def scatter(
39
104
  self,
40
105
  ax: Any,
@@ -151,6 +216,36 @@ class PlotBackend(Protocol):
151
216
  """
152
217
  ...
153
218
 
219
+ def axvline(
220
+ self,
221
+ ax: Any,
222
+ x: float,
223
+ color: str = "grey",
224
+ linestyle: str = "--",
225
+ linewidth: float = 1.0,
226
+ alpha: float = 1.0,
227
+ zorder: int = 1,
228
+ ) -> Any:
229
+ """Add a vertical line across the axes.
230
+
231
+ Args:
232
+ ax: Axes or panel.
233
+ x: X-value for the line.
234
+ color: Line color.
235
+ linestyle: Line style.
236
+ linewidth: Line width.
237
+ alpha: Line transparency (0-1).
238
+ zorder: Drawing order.
239
+
240
+ Returns:
241
+ The line object.
242
+ """
243
+ ...
244
+
245
+ # =========================================================================
246
+ # Text and Annotations
247
+ # =========================================================================
248
+
154
249
  def add_text(
155
250
  self,
156
251
  ax: Any,
@@ -181,6 +276,57 @@ class PlotBackend(Protocol):
181
276
  """
182
277
  ...
183
278
 
279
+ def add_panel_label(
280
+ self,
281
+ ax: Any,
282
+ label: str,
283
+ x_frac: float = 0.02,
284
+ y_frac: float = 0.95,
285
+ ) -> None:
286
+ """Add label text at fractional position in panel.
287
+
288
+ Used for panel letters (A, B, C) in multi-panel figures.
289
+
290
+ Args:
291
+ ax: Axes or panel.
292
+ label: Label text (e.g., "A", "B").
293
+ x_frac: Horizontal position as fraction of axes (0-1).
294
+ y_frac: Vertical position as fraction of axes (0-1).
295
+ """
296
+ ...
297
+
298
+ def add_snp_labels(
299
+ self,
300
+ ax: Any,
301
+ df: pd.DataFrame,
302
+ pos_col: str,
303
+ neglog10p_col: str,
304
+ rs_col: str,
305
+ label_top_n: int,
306
+ genes_df: Optional[pd.DataFrame],
307
+ chrom: int,
308
+ ) -> None:
309
+ """Add SNP labels to plot.
310
+
311
+ No-op if supports_snp_labels=False. Matplotlib uses adjustText
312
+ for automatic label repositioning to avoid overlaps.
313
+
314
+ Args:
315
+ ax: Axes or panel.
316
+ df: DataFrame with SNP data.
317
+ pos_col: Column name for position.
318
+ neglog10p_col: Column name for -log10(p-value).
319
+ rs_col: Column name for SNP ID.
320
+ label_top_n: Number of top SNPs to label.
321
+ genes_df: Gene annotations (unused, for signature compatibility).
322
+ chrom: Chromosome number (unused, for signature compatibility).
323
+ """
324
+ ...
325
+
326
+ # =========================================================================
327
+ # Shapes and Patches
328
+ # =========================================================================
329
+
184
330
  def add_rectangle(
185
331
  self,
186
332
  ax: Any,
@@ -209,6 +355,36 @@ class PlotBackend(Protocol):
209
355
  """
210
356
  ...
211
357
 
358
+ def add_polygon(
359
+ self,
360
+ ax: Any,
361
+ points: List[List[float]],
362
+ facecolor: str = "blue",
363
+ edgecolor: str = "black",
364
+ linewidth: float = 0.5,
365
+ zorder: int = 2,
366
+ ) -> Any:
367
+ """Add polygon patch to axes.
368
+
369
+ Used for gene track directional arrows.
370
+
371
+ Args:
372
+ ax: Axes or panel.
373
+ points: List of [x, y] coordinate pairs forming the polygon.
374
+ facecolor: Fill color.
375
+ edgecolor: Edge color.
376
+ linewidth: Edge width.
377
+ zorder: Drawing order.
378
+
379
+ Returns:
380
+ The polygon object.
381
+ """
382
+ ...
383
+
384
+ # =========================================================================
385
+ # Axis Configuration
386
+ # =========================================================================
387
+
212
388
  def set_xlim(self, ax: Any, left: float, right: float) -> None:
213
389
  """Set x-axis limits.
214
390
 
@@ -249,6 +425,23 @@ class PlotBackend(Protocol):
249
425
  """
250
426
  ...
251
427
 
428
+ def set_yticks(
429
+ self,
430
+ ax: Any,
431
+ positions: List[float],
432
+ labels: List[str],
433
+ fontsize: int = 10,
434
+ ) -> None:
435
+ """Set y-axis tick positions and labels.
436
+
437
+ Args:
438
+ ax: Axes or panel.
439
+ positions: Tick positions.
440
+ labels: Tick labels.
441
+ fontsize: Font size.
442
+ """
443
+ ...
444
+
252
445
  def set_title(self, ax: Any, title: str, fontsize: int = 14) -> None:
253
446
  """Set panel title.
254
447
 
@@ -259,6 +452,38 @@ class PlotBackend(Protocol):
259
452
  """
260
453
  ...
261
454
 
455
+ def hide_spines(self, ax: Any, spines: List[str]) -> None:
456
+ """Hide specified axis spines.
457
+
458
+ Args:
459
+ ax: Axes or panel.
460
+ spines: List of spine names ('top', 'right', 'bottom', 'left').
461
+ """
462
+ ...
463
+
464
+ def hide_yaxis(self, ax: Any) -> None:
465
+ """Hide y-axis for gene track panels.
466
+
467
+ Hides y-axis ticks, labels, and line. Gene tracks don't need
468
+ a y-axis since the vertical position is just for layout.
469
+
470
+ Args:
471
+ ax: Axes or panel.
472
+ """
473
+ ...
474
+
475
+ def format_xaxis_mb(self, ax: Any) -> None:
476
+ """Format x-axis to show megabase values.
477
+
478
+ Args:
479
+ ax: Axes or panel.
480
+ """
481
+ ...
482
+
483
+ # =========================================================================
484
+ # Secondary Y-Axis (for recombination overlay)
485
+ # =========================================================================
486
+
262
487
  def create_twin_axis(self, ax: Any) -> Any:
263
488
  """Create a secondary y-axis sharing the same x-axis.
264
489
 
@@ -270,75 +495,138 @@ class PlotBackend(Protocol):
270
495
  """
271
496
  ...
272
497
 
273
- def add_legend(
498
+ def line_secondary(
274
499
  self,
275
500
  ax: Any,
276
- handles: List[Any],
277
- labels: List[str],
278
- loc: str = "upper left",
279
- title: Optional[str] = None,
501
+ x: pd.Series,
502
+ y: pd.Series,
503
+ color: str = "blue",
504
+ linewidth: float = 1.5,
505
+ alpha: float = 1.0,
506
+ linestyle: str = "-",
507
+ label: Optional[str] = None,
508
+ yaxis_name: Any = None,
280
509
  ) -> Any:
281
- """Add a legend to the axes.
510
+ """Create line on secondary y-axis.
282
511
 
283
512
  Args:
284
- ax: Axes or panel.
285
- handles: Legend handle objects.
286
- labels: Legend labels.
287
- loc: Legend location.
288
- title: Legend title.
513
+ ax: Axes or panel (may be tuple for Plotly).
514
+ x: X-axis values.
515
+ y: Y-axis values.
516
+ color: Line color.
517
+ linewidth: Line width.
518
+ alpha: Transparency.
519
+ linestyle: Line style.
520
+ label: Legend label.
521
+ yaxis_name: Backend-specific secondary axis identifier.
289
522
 
290
523
  Returns:
291
- The legend object.
524
+ The line object.
292
525
  """
293
526
  ...
294
527
 
295
- def hide_spines(self, ax: Any, spines: List[str]) -> None:
296
- """Hide specified axis spines.
528
+ def fill_between_secondary(
529
+ self,
530
+ ax: Any,
531
+ x: pd.Series,
532
+ y1: Union[float, pd.Series],
533
+ y2: Union[float, pd.Series],
534
+ color: str = "blue",
535
+ alpha: float = 0.3,
536
+ yaxis_name: Any = None,
537
+ ) -> Any:
538
+ """Fill area on secondary y-axis.
297
539
 
298
540
  Args:
299
541
  ax: Axes or panel.
300
- spines: List of spine names ('top', 'right', 'bottom', 'left').
542
+ x: X-axis values.
543
+ y1: Lower y boundary.
544
+ y2: Upper y boundary.
545
+ color: Fill color.
546
+ alpha: Transparency.
547
+ yaxis_name: Backend-specific secondary axis identifier.
548
+
549
+ Returns:
550
+ The fill object.
301
551
  """
302
552
  ...
303
553
 
304
- def format_xaxis_mb(self, ax: Any) -> None:
305
- """Format x-axis to show megabase values.
554
+ def set_secondary_ylim(
555
+ self,
556
+ ax: Any,
557
+ bottom: float,
558
+ top: float,
559
+ yaxis_name: Any = None,
560
+ ) -> None:
561
+ """Set secondary y-axis limits.
306
562
 
307
563
  Args:
308
564
  ax: Axes or panel.
565
+ bottom: Minimum y value.
566
+ top: Maximum y value.
567
+ yaxis_name: Backend-specific secondary axis identifier.
309
568
  """
310
569
  ...
311
570
 
312
- def save(
571
+ def set_secondary_ylabel(
313
572
  self,
314
- fig: Any,
315
- path: str,
316
- dpi: int = 150,
317
- bbox_inches: str = "tight",
573
+ ax: Any,
574
+ label: str,
575
+ color: str = "black",
576
+ fontsize: int = 10,
577
+ yaxis_name: Any = None,
318
578
  ) -> None:
319
- """Save figure to file.
579
+ """Set secondary y-axis label.
320
580
 
321
581
  Args:
322
- fig: Figure object.
323
- path: Output file path (.png, .pdf, .html).
324
- dpi: Resolution for raster formats.
325
- bbox_inches: Bounding box adjustment.
582
+ ax: Axes or panel.
583
+ label: Label text.
584
+ color: Label color.
585
+ fontsize: Font size.
586
+ yaxis_name: Backend-specific secondary axis identifier.
326
587
  """
327
588
  ...
328
589
 
329
- def show(self, fig: Any) -> None:
330
- """Display the figure.
590
+ # =========================================================================
591
+ # Legends
592
+ # =========================================================================
593
+
594
+ def add_legend(
595
+ self,
596
+ ax: Any,
597
+ handles: List[Any],
598
+ labels: List[str],
599
+ loc: str = "upper left",
600
+ title: Optional[str] = None,
601
+ ) -> Any:
602
+ """Add a legend to the axes.
331
603
 
332
604
  Args:
333
- fig: Figure object.
605
+ ax: Axes or panel.
606
+ handles: Legend handle objects.
607
+ labels: Legend labels.
608
+ loc: Legend location.
609
+ title: Legend title.
610
+
611
+ Returns:
612
+ The legend object.
334
613
  """
335
614
  ...
336
615
 
337
- def close(self, fig: Any) -> None:
338
- """Close the figure and free resources.
616
+ def add_ld_legend(
617
+ self,
618
+ ax: Any,
619
+ ld_bins: List[Tuple[float, str, str]],
620
+ lead_snp_color: str,
621
+ ) -> None:
622
+ """Add LD color legend.
623
+
624
+ Shows the linkage disequilibrium (r^2) color scale and lead SNP marker.
339
625
 
340
626
  Args:
341
- fig: Figure object.
627
+ ax: Axes or panel.
628
+ ld_bins: List of (threshold, label, color) tuples defining LD bins.
629
+ lead_snp_color: Color for lead SNP marker in legend.
342
630
  """
343
631
  ...
344
632
 
@@ -361,7 +649,7 @@ class PlotBackend(Protocol):
361
649
  self,
362
650
  ax: Any,
363
651
  credible_sets: List[int],
364
- get_color_func: Any,
652
+ get_color_func: Callable[[int], str],
365
653
  ) -> None:
366
654
  """Add fine-mapping credible set legend to the axes.
367
655
 
@@ -387,31 +675,9 @@ class PlotBackend(Protocol):
387
675
  """
388
676
  ...
389
677
 
390
- def axvline(
391
- self,
392
- ax: Any,
393
- x: float,
394
- color: str = "grey",
395
- linestyle: str = "--",
396
- linewidth: float = 1.0,
397
- alpha: float = 1.0,
398
- zorder: int = 1,
399
- ) -> Any:
400
- """Add a vertical line across the axes.
401
-
402
- Args:
403
- ax: Axes or panel.
404
- x: X-value for the line.
405
- color: Line color.
406
- linestyle: Line style.
407
- linewidth: Line width.
408
- alpha: Line transparency (0-1).
409
- zorder: Drawing order.
410
-
411
- Returns:
412
- The line object.
413
- """
414
- ...
678
+ # =========================================================================
679
+ # Specialized Charts
680
+ # =========================================================================
415
681
 
416
682
  def hbar(
417
683
  self,
@@ -472,3 +738,40 @@ class PlotBackend(Protocol):
472
738
  The errorbar object.
473
739
  """
474
740
  ...
741
+
742
+ # =========================================================================
743
+ # File Operations
744
+ # =========================================================================
745
+
746
+ def save(
747
+ self,
748
+ fig: Any,
749
+ path: str,
750
+ dpi: int = 150,
751
+ bbox_inches: str = "tight",
752
+ ) -> None:
753
+ """Save figure to file.
754
+
755
+ Args:
756
+ fig: Figure object.
757
+ path: Output file path (.png, .pdf, .html).
758
+ dpi: Resolution for raster formats.
759
+ bbox_inches: Bounding box adjustment.
760
+ """
761
+ ...
762
+
763
+ def show(self, fig: Any) -> None:
764
+ """Display the figure.
765
+
766
+ Args:
767
+ fig: Figure object.
768
+ """
769
+ ...
770
+
771
+ def close(self, fig: Any) -> None:
772
+ """Close the figure and free resources.
773
+
774
+ Args:
775
+ fig: Figure object.
776
+ """
777
+ ...
@@ -11,7 +11,10 @@ from bokeh.layouts import column
11
11
  from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
12
12
  from bokeh.plotting import figure
13
13
 
14
+ from . import convert_latex_to_unicode, register_backend
14
15
 
16
+
17
+ @register_backend("bokeh")
15
18
  class BokehBackend:
16
19
  """Bokeh backend for interactive plot generation.
17
20
 
@@ -34,9 +37,20 @@ class BokehBackend:
34
37
  "-.": "dashdot",
35
38
  }
36
39
 
37
- def __init__(self) -> None:
38
- """Initialize the bokeh backend."""
39
- pass
40
+ @property
41
+ def supports_snp_labels(self) -> bool:
42
+ """Bokeh uses hover tooltips instead of labels."""
43
+ return False
44
+
45
+ @property
46
+ def supports_hover(self) -> bool:
47
+ """Bokeh supports hover tooltips."""
48
+ return True
49
+
50
+ @property
51
+ def supports_secondary_axis(self) -> bool:
52
+ """Bokeh supports secondary y-axis."""
53
+ return True
40
54
 
41
55
  def create_figure(
42
56
  self,
@@ -356,6 +370,18 @@ class BokehBackend:
356
370
  ax.yaxis.axis_label = label
357
371
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
358
372
 
373
+ def set_yticks(
374
+ self,
375
+ ax: figure,
376
+ positions: List[float],
377
+ labels: List[str],
378
+ fontsize: int = 10,
379
+ ) -> None:
380
+ """Set y-axis tick positions and labels."""
381
+ ax.yaxis.ticker = positions
382
+ ax.yaxis.major_label_overrides = dict(zip(positions, labels))
383
+ ax.yaxis.major_label_text_font_size = f"{fontsize}pt"
384
+
359
385
  def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
360
386
  """Map matplotlib-style legend location to Bokeh location."""
361
387
  loc_map = {
@@ -368,18 +394,7 @@ class BokehBackend:
368
394
 
369
395
  def _convert_label(self, label: str) -> str:
370
396
  """Convert LaTeX-style labels to Unicode for Bokeh display."""
371
- conversions = [
372
- (r"$-\log_{10}$ P", "-log₁₀(P)"),
373
- (r"$-\log_{10}$", "-log₁₀"),
374
- (r"\log_{10}", "log₁₀"),
375
- (r"$r^2$", "r²"),
376
- (r"$R^2$", "R²"),
377
- ]
378
- for latex, unicode_str in conversions:
379
- if latex in label:
380
- label = label.replace(latex, unicode_str)
381
- label = label.replace("$", "")
382
- return label
397
+ return convert_latex_to_unicode(label)
383
398
 
384
399
  def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
385
400
  """Set figure title."""
@@ -489,6 +504,53 @@ class BokehBackend:
489
504
  renderer.major_label_text_color = color
490
505
  break
491
506
 
507
+ def add_snp_labels(
508
+ self,
509
+ ax: figure,
510
+ df: pd.DataFrame,
511
+ pos_col: str,
512
+ neglog10p_col: str,
513
+ rs_col: str,
514
+ label_top_n: int,
515
+ genes_df: Optional[pd.DataFrame],
516
+ chrom: int,
517
+ ) -> None:
518
+ """No-op: Bokeh uses hover tooltips instead of text labels."""
519
+ pass
520
+
521
+ def add_panel_label(
522
+ self,
523
+ ax: figure,
524
+ label: str,
525
+ x_frac: float = 0.02,
526
+ y_frac: float = 0.95,
527
+ ) -> None:
528
+ """Add label text at fractional position in panel."""
529
+ from bokeh.models import Label
530
+
531
+ # Convert fraction to data coordinates using axis ranges
532
+ x_range = ax.x_range
533
+ y_range = ax.y_range
534
+ x = (
535
+ x_range.start + x_frac * (x_range.end - x_range.start)
536
+ if hasattr(x_range, "start") and x_range.start is not None
537
+ else 0
538
+ )
539
+ y = (
540
+ y_range.start + y_frac * (y_range.end - y_range.start)
541
+ if hasattr(y_range, "start") and y_range.start is not None
542
+ else 0
543
+ )
544
+
545
+ label_obj = Label(
546
+ x=x,
547
+ y=y,
548
+ text=label,
549
+ text_font_size="12px",
550
+ text_font_style="bold",
551
+ )
552
+ ax.add_layout(label_obj)
553
+
492
554
  def _ensure_legend_range(self, ax: figure) -> Any:
493
555
  """Ensure legend range exists and return a dummy data source.
494
556