pylocuszoom 1.0.0__py3-none-any.whl → 1.1.1__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.6.0"
37
+ __version__ = "1.1.1"
38
38
 
39
39
  # Main plotter class
40
40
  # Backend types
@@ -133,6 +133,9 @@ from .loaders import (
133
133
  # Logging configuration
134
134
  from .logging import disable_logging, enable_logging
135
135
 
136
+ # Manhattan and QQ plotting
137
+ from .manhattan_plotter import ManhattanPlotter
138
+
136
139
  # PheWAS support
137
140
  from .phewas import validate_phewas_df
138
141
  from .plotter import LocusZoomPlotter
@@ -145,6 +148,9 @@ from .recombination import (
145
148
  load_recombination_map,
146
149
  )
147
150
 
151
+ # Statistical visualizations (PheWAS, forest plots)
152
+ from .stats_plotter import StatsPlotter
153
+
148
154
  # Validation utilities
149
155
  from .utils import to_pandas
150
156
 
@@ -152,6 +158,8 @@ __all__ = [
152
158
  # Core
153
159
  "__version__",
154
160
  "LocusZoomPlotter",
161
+ "ManhattanPlotter",
162
+ "StatsPlotter",
155
163
  # Backends
156
164
  "BackendType",
157
165
  "get_backend",
@@ -0,0 +1,66 @@
1
+ """Shared utilities for plotter classes.
2
+
3
+ Internal module - not part of public API.
4
+ """
5
+
6
+ from typing import Any, Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ # Significance thresholds
12
+ DEFAULT_GENOMEWIDE_THRESHOLD = 5e-8
13
+
14
+ # Manhattan/QQ plot styling constants
15
+ MANHATTAN_POINT_SIZE = 10
16
+ MANHATTAN_CATEGORICAL_POINT_SIZE = 30
17
+ QQ_POINT_SIZE = 10
18
+ POINT_EDGE_COLOR = "black"
19
+ MANHATTAN_EDGE_WIDTH = 0.1
20
+ QQ_EDGE_WIDTH = 0.02
21
+ QQ_POINT_COLOR = "#1f77b4"
22
+ QQ_CI_COLOR = "#CCCCCC"
23
+ QQ_CI_ALPHA = 0.5
24
+ SIGNIFICANCE_LINE_COLOR = "red"
25
+
26
+
27
+ def transform_pvalues(df: pd.DataFrame, p_col: str) -> pd.DataFrame:
28
+ """Add neglog10p column with -log10 transformed p-values.
29
+
30
+ Clips extremely small p-values to 1e-300 to avoid -inf.
31
+
32
+ Args:
33
+ df: DataFrame with p-value column.
34
+ p_col: Name of p-value column.
35
+
36
+ Returns:
37
+ DataFrame with neglog10p column added.
38
+ """
39
+ df = df.copy()
40
+ df["neglog10p"] = -np.log10(df[p_col].clip(lower=1e-300))
41
+ return df
42
+
43
+
44
+ def add_significance_line(
45
+ backend: Any,
46
+ ax: Any,
47
+ threshold: Optional[float],
48
+ ) -> None:
49
+ """Add genome-wide significance threshold line.
50
+
51
+ Args:
52
+ backend: Plot backend instance.
53
+ ax: Axes object from backend.
54
+ threshold: P-value threshold (e.g., 5e-8). None to skip.
55
+ """
56
+ if threshold is None:
57
+ return
58
+ threshold_line = -np.log10(threshold)
59
+ backend.axhline(
60
+ ax,
61
+ y=threshold_line,
62
+ color=SIGNIFICANCE_LINE_COLOR,
63
+ linestyle="--",
64
+ linewidth=1,
65
+ zorder=1,
66
+ )
@@ -96,6 +96,31 @@ class PlotBackend(Protocol):
96
96
  """
97
97
  ...
98
98
 
99
+ def create_figure_grid(
100
+ self,
101
+ n_rows: int,
102
+ n_cols: int,
103
+ width_ratios: Optional[List[float]] = None,
104
+ height_ratios: Optional[List[float]] = None,
105
+ figsize: Tuple[float, float] = (12.0, 8.0),
106
+ ) -> Tuple[Any, List[Any]]:
107
+ """Create a figure with a grid of subplots.
108
+
109
+ Unlike create_figure which creates vertically stacked panels,
110
+ this creates a 2D grid of subplots.
111
+
112
+ Args:
113
+ n_rows: Number of rows.
114
+ n_cols: Number of columns.
115
+ width_ratios: Relative widths for columns.
116
+ height_ratios: Relative heights for rows.
117
+ figsize: Figure size as (width, height).
118
+
119
+ Returns:
120
+ Tuple of (figure, flattened list of axes).
121
+ """
122
+ ...
123
+
99
124
  # =========================================================================
100
125
  # Basic Plotting
101
126
  # =========================================================================
@@ -442,6 +467,27 @@ class PlotBackend(Protocol):
442
467
  """
443
468
  ...
444
469
 
470
+ def set_xticks(
471
+ self,
472
+ ax: Any,
473
+ positions: List[float],
474
+ labels: List[str],
475
+ fontsize: int = 10,
476
+ rotation: int = 0,
477
+ ha: str = "center",
478
+ ) -> None:
479
+ """Set x-axis tick positions and labels.
480
+
481
+ Args:
482
+ ax: Axes or panel.
483
+ positions: Tick positions.
484
+ labels: Tick labels.
485
+ fontsize: Font size.
486
+ rotation: Label rotation in degrees.
487
+ ha: Horizontal alignment for rotated labels.
488
+ """
489
+ ...
490
+
445
491
  def set_title(self, ax: Any, title: str, fontsize: int = 14) -> None:
446
492
  """Set panel title.
447
493
 
@@ -452,6 +498,16 @@ class PlotBackend(Protocol):
452
498
  """
453
499
  ...
454
500
 
501
+ def set_suptitle(self, fig: Any, title: str, fontsize: int = 14) -> None:
502
+ """Set overall figure title (super title).
503
+
504
+ Args:
505
+ fig: Figure object.
506
+ title: Title text.
507
+ fontsize: Font size.
508
+ """
509
+ ...
510
+
455
511
  def hide_spines(self, ax: Any, spines: List[str]) -> None:
456
512
  """Hide specified axis spines.
457
513
 
@@ -6,13 +6,27 @@ Interactive backend with hover tooltips, well-suited for dashboards.
6
6
  from typing import Any, List, Optional, Tuple, Union
7
7
 
8
8
  import pandas as pd
9
- from bokeh.io import export_png, export_svgs, output_file, save, show
10
- from bokeh.layouts import column
9
+ from bokeh.layouts import column, row
11
10
  from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
12
11
  from bokeh.plotting import figure
13
12
 
14
13
  from . import convert_latex_to_unicode, register_backend
15
14
 
15
+ # Style mappings (matplotlib -> Bokeh)
16
+ _MARKER_MAP = {
17
+ "o": "circle",
18
+ "D": "diamond",
19
+ "s": "square",
20
+ "^": "triangle",
21
+ "v": "inverted_triangle",
22
+ }
23
+ _DASH_MAP = {
24
+ "-": "solid",
25
+ "--": "dashed",
26
+ ":": "dotted",
27
+ "-.": "dashdot",
28
+ }
29
+
16
30
 
17
31
  @register_backend("bokeh")
18
32
  class BokehBackend:
@@ -22,21 +36,6 @@ class BokehBackend:
22
36
  applications and dashboards.
23
37
  """
24
38
 
25
- # Class constants for style mappings
26
- _MARKER_MAP = {
27
- "o": "circle",
28
- "D": "diamond",
29
- "s": "square",
30
- "^": "triangle",
31
- "v": "inverted_triangle",
32
- }
33
- _DASH_MAP = {
34
- "-": "solid",
35
- "--": "dashed",
36
- ":": "dotted",
37
- "-.": "dashdot",
38
- }
39
-
40
39
  @property
41
40
  def supports_snp_labels(self) -> bool:
42
41
  """Bokeh uses hover tooltips instead of labels."""
@@ -105,6 +104,73 @@ class BokehBackend:
105
104
 
106
105
  return layout, figures
107
106
 
107
+ def create_figure_grid(
108
+ self,
109
+ n_rows: int,
110
+ n_cols: int,
111
+ width_ratios: Optional[List[float]] = None,
112
+ height_ratios: Optional[List[float]] = None,
113
+ figsize: Tuple[float, float] = (12.0, 8.0),
114
+ ) -> Tuple[Any, List[figure]]:
115
+ """Create a layout with a grid of subplots.
116
+
117
+ Args:
118
+ n_rows: Number of rows.
119
+ n_cols: Number of columns.
120
+ width_ratios: Relative widths for columns.
121
+ height_ratios: Relative heights for rows.
122
+ figsize: Figure size as (width, height).
123
+
124
+ Returns:
125
+ Tuple of (layout, flattened list of figure objects).
126
+ """
127
+ width_px = int(figsize[0] * 100)
128
+ height_px = int(figsize[1] * 100)
129
+
130
+ # Calculate widths
131
+ if width_ratios is not None:
132
+ total_w = sum(width_ratios)
133
+ widths = [int(width_px * w / total_w) for w in width_ratios]
134
+ else:
135
+ widths = [width_px // n_cols] * n_cols
136
+
137
+ # Calculate heights
138
+ if height_ratios is not None:
139
+ total_h = sum(height_ratios)
140
+ heights = [int(height_px * h / total_h) for h in height_ratios]
141
+ else:
142
+ heights = [height_px // n_rows] * n_rows
143
+
144
+ figures = []
145
+ rows = []
146
+
147
+ for i in range(n_rows):
148
+ row_figures = []
149
+ for j in range(n_cols):
150
+ p = figure(
151
+ width=widths[j],
152
+ height=heights[i],
153
+ tools="pan,wheel_zoom,box_zoom,reset,save",
154
+ toolbar_location="above" if i == 0 and j == 0 else None,
155
+ )
156
+
157
+ # Style
158
+ p.grid.visible = False
159
+ p.outline_line_color = None
160
+ p.xaxis.axis_line_color = "black"
161
+ p.yaxis.axis_line_color = "black"
162
+ p.xaxis.minor_tick_line_color = None
163
+ p.yaxis.minor_tick_line_color = None
164
+
165
+ row_figures.append(p)
166
+ figures.append(p)
167
+
168
+ rows.append(row(*row_figures))
169
+
170
+ layout = column(*rows)
171
+
172
+ return layout, figures
173
+
108
174
  def scatter(
109
175
  self,
110
176
  ax: figure,
@@ -153,8 +219,7 @@ class BokehBackend:
153
219
 
154
220
  source = ColumnDataSource(data)
155
221
 
156
- # Get marker type for scatter()
157
- marker_type = self._MARKER_MAP.get(marker, "circle")
222
+ marker_type = _MARKER_MAP.get(marker, "circle")
158
223
 
159
224
  # Create scatter using scatter() method (Bokeh 3.4+ preferred API)
160
225
  scatter_kwargs = {
@@ -194,7 +259,7 @@ class BokehBackend:
194
259
  label: Optional[str] = None,
195
260
  ) -> Any:
196
261
  """Create a line plot on the given figure."""
197
- line_dash = self._DASH_MAP.get(linestyle, "solid")
262
+ line_dash = _DASH_MAP.get(linestyle, "solid")
198
263
 
199
264
  line_kwargs = {
200
265
  "line_color": color,
@@ -249,7 +314,7 @@ class BokehBackend:
249
314
  zorder: int = 1,
250
315
  ) -> Any:
251
316
  """Add a horizontal line across the figure."""
252
- line_dash = self._DASH_MAP.get(linestyle, "dashed")
317
+ line_dash = _DASH_MAP.get(linestyle, "dashed")
253
318
 
254
319
  span = Span(
255
320
  location=y,
@@ -379,9 +444,31 @@ class BokehBackend:
379
444
  ) -> None:
380
445
  """Set y-axis tick positions and labels."""
381
446
  ax.yaxis.ticker = positions
382
- ax.yaxis.major_label_overrides = dict(zip(positions, labels))
447
+ ax.yaxis.major_label_overrides = {
448
+ pos: label for pos, label in zip(positions, labels)
449
+ }
383
450
  ax.yaxis.major_label_text_font_size = f"{fontsize}pt"
384
451
 
452
+ def set_xticks(
453
+ self,
454
+ ax: figure,
455
+ positions: List[float],
456
+ labels: List[str],
457
+ fontsize: int = 10,
458
+ rotation: int = 0,
459
+ ha: str = "center",
460
+ ) -> None:
461
+ """Set x-axis tick positions and labels."""
462
+ ax.xaxis.ticker = positions
463
+ ax.xaxis.major_label_overrides = {
464
+ pos: label for pos, label in zip(positions, labels)
465
+ }
466
+ ax.xaxis.major_label_text_font_size = f"{fontsize}pt"
467
+ if rotation:
468
+ ax.xaxis.major_label_orientation = (
469
+ rotation * 3.14159 / 180
470
+ ) # Convert to radians
471
+
385
472
  def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
386
473
  """Map matplotlib-style legend location to Bokeh location."""
387
474
  loc_map = {
@@ -401,6 +488,22 @@ class BokehBackend:
401
488
  ax.title.text = title
402
489
  ax.title.text_font_size = f"{fontsize}pt"
403
490
 
491
+ def set_suptitle(self, fig: Any, title: str, fontsize: int = 14) -> None:
492
+ """Set overall figure title.
493
+
494
+ For Bokeh layouts, add title to the first figure in the layout.
495
+ """
496
+ from bokeh.models.layouts import Column
497
+
498
+ if isinstance(fig, Column) and len(fig.children) > 0:
499
+ first_child = fig.children[0]
500
+ if hasattr(first_child, "title"):
501
+ first_child.title.text = title
502
+ first_child.title.text_font_size = f"{fontsize}pt"
503
+ elif hasattr(fig, "title"):
504
+ fig.title.text = title
505
+ fig.title.text_font_size = f"{fontsize}pt"
506
+
404
507
  def create_twin_axis(self, ax: figure) -> Any:
405
508
  """Create a secondary y-axis.
406
509
 
@@ -408,9 +511,15 @@ class BokehBackend:
408
511
  """
409
512
  from bokeh.models import LinearAxis, Range1d
410
513
 
411
- # Add a second y-axis
514
+ # Add a second y-axis without tick marks (cleaner look)
412
515
  ax.extra_y_ranges = {"secondary": Range1d(start=0, end=100)}
413
- ax.add_layout(LinearAxis(y_range_name="secondary"), "right")
516
+ secondary_axis = LinearAxis(
517
+ y_range_name="secondary",
518
+ major_tick_line_color=None, # Hide major ticks
519
+ minor_tick_line_color=None, # Hide minor ticks
520
+ major_label_text_font_size="0pt", # Hide tick labels
521
+ )
522
+ ax.add_layout(secondary_axis, "right")
414
523
 
415
524
  return "secondary"
416
525
 
@@ -427,7 +536,7 @@ class BokehBackend:
427
536
  yaxis_name: str = "secondary",
428
537
  ) -> Any:
429
538
  """Create a line plot on secondary y-axis."""
430
- line_dash = self._DASH_MAP.get(linestyle, "solid")
539
+ line_dash = _DASH_MAP.get(linestyle, "solid")
431
540
 
432
541
  return ax.line(
433
542
  x.values,
@@ -570,7 +679,7 @@ class BokehBackend:
570
679
  label: str,
571
680
  color: str,
572
681
  marker: str,
573
- size: int = 10,
682
+ size: int = 14,
574
683
  ) -> Any:
575
684
  """Create an invisible scatter renderer for a legend entry."""
576
685
  from bokeh.models import LegendItem
@@ -619,7 +728,7 @@ class BokehBackend:
619
728
  """
620
729
  source = self._ensure_legend_range(ax)
621
730
  items = [
622
- self._add_legend_item(ax, source, "Lead SNP", lead_snp_color, "diamond", 12)
731
+ self._add_legend_item(ax, source, "Lead SNP", lead_snp_color, "diamond", 16)
623
732
  ]
624
733
  for _, label, color in ld_bins:
625
734
  items.append(self._add_legend_item(ax, source, label, color, "square"))
@@ -674,6 +783,8 @@ class BokehBackend:
674
783
 
675
784
  Supports .html for interactive and .png for static.
676
785
  """
786
+ from bokeh.io import export_png, export_svgs, output_file, save
787
+
677
788
  if path.endswith(".html"):
678
789
  output_file(path)
679
790
  save(fig)
@@ -682,12 +793,13 @@ class BokehBackend:
682
793
  elif path.endswith(".svg"):
683
794
  export_svgs(fig, filename=path)
684
795
  else:
685
- # Default to HTML
686
796
  output_file(path)
687
797
  save(fig)
688
798
 
689
799
  def show(self, fig: Any) -> None:
690
800
  """Display the figure."""
801
+ from bokeh.io import show
802
+
691
803
  show(fig)
692
804
 
693
805
  def close(self, fig: Any) -> None:
@@ -756,7 +868,7 @@ class BokehBackend:
756
868
  zorder: int = 1,
757
869
  ) -> Any:
758
870
  """Add a vertical line across the figure."""
759
- line_dash = self._DASH_MAP.get(linestyle, "dashed")
871
+ line_dash = _DASH_MAP.get(linestyle, "dashed")
760
872
 
761
873
  span = Span(
762
874
  location=x,
@@ -82,6 +82,48 @@ class MatplotlibBackend:
82
82
 
83
83
  return fig, list(axes)
84
84
 
85
+ def create_figure_grid(
86
+ self,
87
+ n_rows: int,
88
+ n_cols: int,
89
+ width_ratios: Optional[List[float]] = None,
90
+ height_ratios: Optional[List[float]] = None,
91
+ figsize: Tuple[float, float] = (12.0, 8.0),
92
+ ) -> Tuple[Figure, List[Axes]]:
93
+ """Create a figure with a grid of subplots.
94
+
95
+ Args:
96
+ n_rows: Number of rows.
97
+ n_cols: Number of columns.
98
+ width_ratios: Relative widths for columns.
99
+ height_ratios: Relative heights for rows.
100
+ figsize: Figure size as (width, height).
101
+
102
+ Returns:
103
+ Tuple of (figure, flattened list of axes).
104
+ """
105
+ plt.ioff()
106
+
107
+ gridspec_kw = {}
108
+ if width_ratios is not None:
109
+ gridspec_kw["width_ratios"] = width_ratios
110
+ if height_ratios is not None:
111
+ gridspec_kw["height_ratios"] = height_ratios
112
+
113
+ fig, axes = plt.subplots(
114
+ n_rows,
115
+ n_cols,
116
+ figsize=figsize,
117
+ gridspec_kw=gridspec_kw if gridspec_kw else None,
118
+ )
119
+
120
+ # Flatten axes to list
121
+ import numpy as np
122
+
123
+ if isinstance(axes, np.ndarray):
124
+ return fig, list(axes.flatten())
125
+ return fig, [axes]
126
+
85
127
  def scatter(
86
128
  self,
87
129
  ax: Axes,
@@ -320,6 +362,19 @@ class MatplotlibBackend:
320
362
  ax.set_yticks(positions)
321
363
  ax.set_yticklabels(labels, fontsize=fontsize)
322
364
 
365
+ def set_xticks(
366
+ self,
367
+ ax: Axes,
368
+ positions: List[float],
369
+ labels: List[str],
370
+ fontsize: int = 10,
371
+ rotation: int = 0,
372
+ ha: str = "center",
373
+ ) -> None:
374
+ """Set x-axis tick positions and labels."""
375
+ ax.set_xticks(positions)
376
+ ax.set_xticklabels(labels, fontsize=fontsize, rotation=rotation, ha=ha)
377
+
323
378
  def set_title(self, ax: Axes, title: str, fontsize: int = 14) -> None:
324
379
  """Set panel title."""
325
380
  ax.set_title(
@@ -329,6 +384,10 @@ class MatplotlibBackend:
329
384
  fontfamily="sans-serif",
330
385
  )
331
386
 
387
+ def set_suptitle(self, fig: Figure, title: str, fontsize: int = 14) -> None:
388
+ """Set overall figure title (super title)."""
389
+ fig.suptitle(title, fontsize=fontsize, fontweight="bold")
390
+
332
391
  def create_twin_axis(self, ax: Axes) -> Axes:
333
392
  """Create a secondary y-axis sharing the same x-axis."""
334
393
  return ax.twinx()
@@ -445,6 +504,7 @@ class MatplotlibBackend:
445
504
  yaxis_name: Ignored for matplotlib.
446
505
  """
447
506
  ax.set_ylabel(label, fontsize=fontsize, color=color)
507
+ ax.tick_params(axis="y", labelcolor=color, labelsize=fontsize - 1)
448
508
 
449
509
  def add_legend(
450
510
  self,