pylocuszoom 0.1.0__py3-none-any.whl → 0.3.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 +39 -20
- pylocuszoom/backends/__init__.py +1 -5
- pylocuszoom/backends/base.py +3 -1
- pylocuszoom/backends/bokeh_backend.py +220 -51
- pylocuszoom/backends/matplotlib_backend.py +35 -8
- pylocuszoom/backends/plotly_backend.py +273 -32
- pylocuszoom/colors.py +132 -0
- pylocuszoom/eqtl.py +3 -2
- pylocuszoom/finemapping.py +223 -0
- pylocuszoom/gene_track.py +259 -38
- pylocuszoom/labels.py +32 -33
- pylocuszoom/ld.py +8 -7
- pylocuszoom/plotter.py +615 -162
- pylocuszoom/recombination.py +14 -14
- pylocuszoom/utils.py +3 -1
- {pylocuszoom-0.1.0.dist-info → pylocuszoom-0.3.0.dist-info}/METADATA +36 -27
- pylocuszoom-0.3.0.dist-info/RECORD +21 -0
- pylocuszoom-0.1.0.dist-info/RECORD +0 -20
- {pylocuszoom-0.1.0.dist-info → pylocuszoom-0.3.0.dist-info}/WHEEL +0 -0
- {pylocuszoom-0.1.0.dist-info → pylocuszoom-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
pylocuszoom/__init__.py
CHANGED
|
@@ -3,20 +3,21 @@
|
|
|
3
3
|
This package provides LocusZoom-style regional association plots with:
|
|
4
4
|
- LD coloring based on R² with lead variant
|
|
5
5
|
- Gene and exon tracks
|
|
6
|
-
- Recombination rate overlays (
|
|
6
|
+
- Recombination rate overlays (canine built-in, or user-provided)
|
|
7
7
|
- Automatic SNP labeling
|
|
8
8
|
- Multiple backends: matplotlib (static), plotly (interactive), bokeh (dashboards)
|
|
9
9
|
- eQTL overlay support
|
|
10
|
+
- Fine-mapping/SuSiE visualization (PIP line with credible set coloring)
|
|
10
11
|
- PySpark DataFrame support for large-scale data
|
|
11
12
|
|
|
12
13
|
Example:
|
|
13
14
|
>>> from pylocuszoom import LocusZoomPlotter
|
|
14
|
-
>>> plotter = LocusZoomPlotter(species="
|
|
15
|
+
>>> plotter = LocusZoomPlotter(species="canine")
|
|
15
16
|
>>> fig = plotter.plot(gwas_df, chrom=1, start=1000000, end=2000000)
|
|
16
17
|
>>> fig.savefig("regional_plot.png", dpi=150)
|
|
17
18
|
|
|
18
19
|
Interactive example:
|
|
19
|
-
>>> plotter = LocusZoomPlotter(species="
|
|
20
|
+
>>> plotter = LocusZoomPlotter(species="canine", backend="plotly")
|
|
20
21
|
>>> fig = plotter.plot(gwas_df, chrom=1, start=1000000, end=2000000)
|
|
21
22
|
>>> fig.write_html("regional_plot.html")
|
|
22
23
|
|
|
@@ -28,22 +29,42 @@ Stacked plots:
|
|
|
28
29
|
... )
|
|
29
30
|
|
|
30
31
|
Species Support:
|
|
31
|
-
-
|
|
32
|
-
-
|
|
32
|
+
- Canine (Canis lupus familiaris): Full features including built-in recombination maps
|
|
33
|
+
- Feline (Felis catus): LD coloring and gene tracks (user provides recombination data)
|
|
33
34
|
- Custom: User provides all reference data
|
|
34
35
|
"""
|
|
35
36
|
|
|
36
37
|
__version__ = "0.1.0"
|
|
37
38
|
|
|
38
39
|
# Main plotter class
|
|
39
|
-
from .plotter import LocusZoomPlotter
|
|
40
|
-
|
|
41
40
|
# Backend types
|
|
42
41
|
from .backends import BackendType, get_backend
|
|
43
42
|
|
|
44
43
|
# Colors and LD
|
|
45
44
|
from .colors import LEAD_SNP_COLOR, get_ld_bin, get_ld_color, get_ld_color_palette
|
|
46
45
|
|
|
46
|
+
# eQTL support
|
|
47
|
+
from .eqtl import (
|
|
48
|
+
EQTLValidationError,
|
|
49
|
+
calculate_colocalization_overlap,
|
|
50
|
+
filter_eqtl_by_gene,
|
|
51
|
+
filter_eqtl_by_region,
|
|
52
|
+
get_eqtl_genes,
|
|
53
|
+
prepare_eqtl_for_plotting,
|
|
54
|
+
validate_eqtl_df,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Fine-mapping/SuSiE support
|
|
58
|
+
from .finemapping import (
|
|
59
|
+
FinemappingValidationError,
|
|
60
|
+
filter_by_credible_set,
|
|
61
|
+
filter_finemapping_by_region,
|
|
62
|
+
get_credible_sets,
|
|
63
|
+
get_top_pip_variants,
|
|
64
|
+
prepare_finemapping_for_plotting,
|
|
65
|
+
validate_finemapping_df,
|
|
66
|
+
)
|
|
67
|
+
|
|
47
68
|
# Gene track
|
|
48
69
|
from .gene_track import get_nearest_gene, plot_gene_track
|
|
49
70
|
|
|
@@ -55,26 +76,16 @@ from .ld import calculate_ld
|
|
|
55
76
|
|
|
56
77
|
# Logging configuration
|
|
57
78
|
from .logging import disable_logging, enable_logging
|
|
79
|
+
from .plotter import LocusZoomPlotter
|
|
58
80
|
|
|
59
81
|
# Reference data management
|
|
60
82
|
from .recombination import (
|
|
61
83
|
add_recombination_overlay,
|
|
62
|
-
|
|
84
|
+
download_canine_recombination_maps,
|
|
63
85
|
get_recombination_rate_for_region,
|
|
64
86
|
load_recombination_map,
|
|
65
87
|
)
|
|
66
88
|
|
|
67
|
-
# eQTL support
|
|
68
|
-
from .eqtl import (
|
|
69
|
-
EQTLValidationError,
|
|
70
|
-
calculate_colocalization_overlap,
|
|
71
|
-
filter_eqtl_by_gene,
|
|
72
|
-
filter_eqtl_by_region,
|
|
73
|
-
get_eqtl_genes,
|
|
74
|
-
prepare_eqtl_for_plotting,
|
|
75
|
-
validate_eqtl_df,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
89
|
# Validation utilities
|
|
79
90
|
from .utils import ValidationError, to_pandas
|
|
80
91
|
|
|
@@ -86,7 +97,7 @@ __all__ = [
|
|
|
86
97
|
"BackendType",
|
|
87
98
|
"get_backend",
|
|
88
99
|
# Reference data
|
|
89
|
-
"
|
|
100
|
+
"download_canine_recombination_maps",
|
|
90
101
|
# Colors
|
|
91
102
|
"get_ld_color",
|
|
92
103
|
"get_ld_bin",
|
|
@@ -111,6 +122,14 @@ __all__ = [
|
|
|
111
122
|
"get_eqtl_genes",
|
|
112
123
|
"calculate_colocalization_overlap",
|
|
113
124
|
"EQTLValidationError",
|
|
125
|
+
# Fine-mapping/SuSiE
|
|
126
|
+
"validate_finemapping_df",
|
|
127
|
+
"filter_finemapping_by_region",
|
|
128
|
+
"filter_by_credible_set",
|
|
129
|
+
"get_credible_sets",
|
|
130
|
+
"get_top_pip_variants",
|
|
131
|
+
"prepare_finemapping_for_plotting",
|
|
132
|
+
"FinemappingValidationError",
|
|
114
133
|
# Logging
|
|
115
134
|
"enable_logging",
|
|
116
135
|
"disable_logging",
|
pylocuszoom/backends/__init__.py
CHANGED
|
@@ -3,15 +3,11 @@
|
|
|
3
3
|
Supports matplotlib (default), plotly, and bokeh backends.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import Literal
|
|
7
7
|
|
|
8
8
|
from .base import PlotBackend
|
|
9
9
|
from .matplotlib_backend import MatplotlibBackend
|
|
10
10
|
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from .bokeh_backend import BokehBackend
|
|
13
|
-
from .plotly_backend import PlotlyBackend
|
|
14
|
-
|
|
15
11
|
BackendType = Literal["matplotlib", "plotly", "bokeh"]
|
|
16
12
|
|
|
17
13
|
_BACKENDS: dict[str, type[PlotBackend]] = {
|
pylocuszoom/backends/base.py
CHANGED
|
@@ -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,
|
|
6
|
+
from typing import Any, List, Optional, Protocol, Tuple, Union
|
|
7
7
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
|
|
@@ -132,6 +132,7 @@ class PlotBackend(Protocol):
|
|
|
132
132
|
color: str = "grey",
|
|
133
133
|
linestyle: str = "--",
|
|
134
134
|
linewidth: float = 1.0,
|
|
135
|
+
alpha: float = 1.0,
|
|
135
136
|
zorder: int = 1,
|
|
136
137
|
) -> Any:
|
|
137
138
|
"""Add a horizontal line across the axes.
|
|
@@ -142,6 +143,7 @@ class PlotBackend(Protocol):
|
|
|
142
143
|
color: Line color.
|
|
143
144
|
linestyle: Line style.
|
|
144
145
|
linewidth: Line width.
|
|
146
|
+
alpha: Line transparency (0-1).
|
|
145
147
|
zorder: Drawing order.
|
|
146
148
|
|
|
147
149
|
Returns:
|
|
@@ -8,7 +8,7 @@ from typing import Any, List, Optional, Tuple, Union
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
from bokeh.io import export_png, export_svgs, output_file, save, show
|
|
10
10
|
from bokeh.layouts import column
|
|
11
|
-
from bokeh.models import ColumnDataSource,
|
|
11
|
+
from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
|
|
12
12
|
from bokeh.plotting import figure
|
|
13
13
|
|
|
14
14
|
|
|
@@ -56,29 +56,25 @@ class BokehBackend:
|
|
|
56
56
|
heights = [int(total_height * r / total_ratio) for r in height_ratios]
|
|
57
57
|
|
|
58
58
|
figures = []
|
|
59
|
-
x_range = None
|
|
59
|
+
x_range = DataRange1d() if sharex else None
|
|
60
60
|
|
|
61
61
|
for i, h in enumerate(heights):
|
|
62
62
|
p = figure(
|
|
63
63
|
width=width_px,
|
|
64
64
|
height=h,
|
|
65
|
-
x_range=x_range if sharex
|
|
65
|
+
x_range=x_range if sharex else DataRange1d(),
|
|
66
66
|
tools="pan,wheel_zoom,box_zoom,reset,save",
|
|
67
67
|
toolbar_location="above" if i == 0 else None,
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
-
# Share x_range for subsequent figures
|
|
71
|
-
if sharex and x_range is None:
|
|
72
|
-
x_range = p.x_range
|
|
73
|
-
|
|
74
70
|
# Style
|
|
75
71
|
p.grid.grid_line_alpha = 0.3
|
|
76
72
|
p.outline_line_color = None
|
|
77
73
|
|
|
78
74
|
figures.append(p)
|
|
79
75
|
|
|
80
|
-
# Create column layout
|
|
81
|
-
layout = column(*figures
|
|
76
|
+
# Create column layout (use default sizing mode to avoid validation warnings)
|
|
77
|
+
layout = column(*figures)
|
|
82
78
|
|
|
83
79
|
return layout, figures
|
|
84
80
|
|
|
@@ -108,39 +104,44 @@ class BokehBackend:
|
|
|
108
104
|
|
|
109
105
|
# Handle sizes (convert from area to diameter)
|
|
110
106
|
if isinstance(sizes, (int, float)):
|
|
111
|
-
bokeh_size = max(6, sizes
|
|
107
|
+
bokeh_size = max(6, sizes**0.5)
|
|
112
108
|
data["size"] = [bokeh_size] * len(x)
|
|
113
109
|
else:
|
|
114
|
-
data["size"] = [max(6, s
|
|
110
|
+
data["size"] = [max(6, s**0.5) for s in sizes]
|
|
115
111
|
|
|
116
112
|
# Add hover data
|
|
117
113
|
tooltips = []
|
|
118
114
|
if hover_data is not None:
|
|
119
115
|
for col in hover_data.columns:
|
|
120
116
|
data[col] = hover_data[col].values
|
|
121
|
-
|
|
117
|
+
col_lower = col.lower()
|
|
118
|
+
if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
|
|
122
119
|
tooltips.append((col, "@{" + col + "}{0.2e}"))
|
|
123
|
-
elif "r2" in
|
|
120
|
+
elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
|
|
124
121
|
tooltips.append((col, "@{" + col + "}{0.3f}"))
|
|
122
|
+
elif "pos" in col_lower:
|
|
123
|
+
tooltips.append((col, "@{" + col + "}{0,0}"))
|
|
125
124
|
else:
|
|
126
125
|
tooltips.append((col, f"@{col}"))
|
|
127
126
|
|
|
128
127
|
source = ColumnDataSource(data)
|
|
129
128
|
|
|
130
|
-
# Get marker type
|
|
129
|
+
# Get marker type for scatter()
|
|
131
130
|
marker_type = self._marker_map.get(marker, "circle")
|
|
132
131
|
|
|
133
|
-
# Create scatter
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
# Create scatter using scatter() method (Bokeh 3.4+ preferred API)
|
|
133
|
+
scatter_kwargs = {
|
|
134
|
+
"source": source,
|
|
135
|
+
"marker": marker_type,
|
|
136
|
+
"size": "size",
|
|
137
|
+
"fill_color": "color",
|
|
138
|
+
"line_color": edgecolor,
|
|
139
|
+
"line_width": linewidth,
|
|
140
|
+
}
|
|
141
|
+
if label:
|
|
142
|
+
scatter_kwargs["legend_label"] = label
|
|
143
|
+
|
|
144
|
+
renderer = ax.scatter("x", "y", **scatter_kwargs)
|
|
144
145
|
|
|
145
146
|
# Add hover tool if we have hover data
|
|
146
147
|
if tooltips:
|
|
@@ -175,15 +176,16 @@ class BokehBackend:
|
|
|
175
176
|
}
|
|
176
177
|
line_dash = dash_map.get(linestyle, "solid")
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
legend_label=
|
|
186
|
-
|
|
179
|
+
line_kwargs = {
|
|
180
|
+
"line_color": color,
|
|
181
|
+
"line_width": linewidth,
|
|
182
|
+
"line_alpha": alpha,
|
|
183
|
+
"line_dash": line_dash,
|
|
184
|
+
}
|
|
185
|
+
if label:
|
|
186
|
+
line_kwargs["legend_label"] = label
|
|
187
|
+
|
|
188
|
+
return ax.line(x.values, y.values, **line_kwargs)
|
|
187
189
|
|
|
188
190
|
def fill_between(
|
|
189
191
|
self,
|
|
@@ -223,6 +225,7 @@ class BokehBackend:
|
|
|
223
225
|
color: str = "grey",
|
|
224
226
|
linestyle: str = "--",
|
|
225
227
|
linewidth: float = 1.0,
|
|
228
|
+
alpha: float = 1.0,
|
|
226
229
|
zorder: int = 1,
|
|
227
230
|
) -> Any:
|
|
228
231
|
"""Add a horizontal line across the figure."""
|
|
@@ -235,6 +238,7 @@ class BokehBackend:
|
|
|
235
238
|
line_color=color,
|
|
236
239
|
line_dash=line_dash,
|
|
237
240
|
line_width=linewidth,
|
|
241
|
+
line_alpha=alpha,
|
|
238
242
|
)
|
|
239
243
|
ax.add_layout(span)
|
|
240
244
|
return span
|
|
@@ -289,7 +293,6 @@ class BokehBackend:
|
|
|
289
293
|
zorder: int = 2,
|
|
290
294
|
) -> Any:
|
|
291
295
|
"""Add a rectangle to the figure."""
|
|
292
|
-
from bokeh.models import Rect
|
|
293
296
|
|
|
294
297
|
x_center = xy[0] + width / 2
|
|
295
298
|
y_center = xy[1] + height / 2
|
|
@@ -304,6 +307,28 @@ class BokehBackend:
|
|
|
304
307
|
line_width=linewidth,
|
|
305
308
|
)
|
|
306
309
|
|
|
310
|
+
def add_polygon(
|
|
311
|
+
self,
|
|
312
|
+
ax: figure,
|
|
313
|
+
points: List[List[float]],
|
|
314
|
+
facecolor: str = "blue",
|
|
315
|
+
edgecolor: str = "black",
|
|
316
|
+
linewidth: float = 0.5,
|
|
317
|
+
zorder: int = 2,
|
|
318
|
+
) -> Any:
|
|
319
|
+
"""Add a polygon (e.g., triangle for strand arrows) to the figure."""
|
|
320
|
+
xs = [p[0] for p in points]
|
|
321
|
+
ys = [p[1] for p in points]
|
|
322
|
+
|
|
323
|
+
# Bokeh patch() uses x/y (singular) for single polygon
|
|
324
|
+
return ax.patch(
|
|
325
|
+
x=xs,
|
|
326
|
+
y=ys,
|
|
327
|
+
fill_color=facecolor,
|
|
328
|
+
line_color=edgecolor,
|
|
329
|
+
line_width=linewidth,
|
|
330
|
+
)
|
|
331
|
+
|
|
307
332
|
def set_xlim(self, ax: figure, left: float, right: float) -> None:
|
|
308
333
|
"""Set x-axis limits."""
|
|
309
334
|
ax.x_range.start = left
|
|
@@ -316,14 +341,31 @@ class BokehBackend:
|
|
|
316
341
|
|
|
317
342
|
def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
318
343
|
"""Set x-axis label."""
|
|
344
|
+
label = self._convert_label(label)
|
|
319
345
|
ax.xaxis.axis_label = label
|
|
320
346
|
ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
321
347
|
|
|
322
348
|
def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
323
349
|
"""Set y-axis label."""
|
|
350
|
+
label = self._convert_label(label)
|
|
324
351
|
ax.yaxis.axis_label = label
|
|
325
352
|
ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
326
353
|
|
|
354
|
+
def _convert_label(self, label: str) -> str:
|
|
355
|
+
"""Convert LaTeX-style labels to Unicode for Bokeh display."""
|
|
356
|
+
conversions = [
|
|
357
|
+
(r"$-\log_{10}$ P", "-log₁₀(P)"),
|
|
358
|
+
(r"$-\log_{10}$", "-log₁₀"),
|
|
359
|
+
(r"\log_{10}", "log₁₀"),
|
|
360
|
+
(r"$r^2$", "r²"),
|
|
361
|
+
(r"$R^2$", "R²"),
|
|
362
|
+
]
|
|
363
|
+
for latex, unicode_str in conversions:
|
|
364
|
+
if latex in label:
|
|
365
|
+
label = label.replace(latex, unicode_str)
|
|
366
|
+
label = label.replace("$", "")
|
|
367
|
+
return label
|
|
368
|
+
|
|
327
369
|
def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
|
|
328
370
|
"""Set figure title."""
|
|
329
371
|
ax.title.text = title
|
|
@@ -342,6 +384,141 @@ class BokehBackend:
|
|
|
342
384
|
|
|
343
385
|
return "secondary"
|
|
344
386
|
|
|
387
|
+
def line_secondary(
|
|
388
|
+
self,
|
|
389
|
+
ax: figure,
|
|
390
|
+
x: pd.Series,
|
|
391
|
+
y: pd.Series,
|
|
392
|
+
color: str = "blue",
|
|
393
|
+
linewidth: float = 1.5,
|
|
394
|
+
alpha: float = 1.0,
|
|
395
|
+
linestyle: str = "-",
|
|
396
|
+
label: Optional[str] = None,
|
|
397
|
+
yaxis_name: str = "secondary",
|
|
398
|
+
) -> Any:
|
|
399
|
+
"""Create a line plot on secondary y-axis."""
|
|
400
|
+
dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
|
|
401
|
+
line_dash = dash_map.get(linestyle, "solid")
|
|
402
|
+
|
|
403
|
+
return ax.line(
|
|
404
|
+
x.values,
|
|
405
|
+
y.values,
|
|
406
|
+
line_color=color,
|
|
407
|
+
line_width=linewidth,
|
|
408
|
+
line_alpha=alpha,
|
|
409
|
+
line_dash=line_dash,
|
|
410
|
+
y_range_name=yaxis_name,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def fill_between_secondary(
|
|
414
|
+
self,
|
|
415
|
+
ax: figure,
|
|
416
|
+
x: pd.Series,
|
|
417
|
+
y1: Union[float, pd.Series],
|
|
418
|
+
y2: Union[float, pd.Series],
|
|
419
|
+
color: str = "blue",
|
|
420
|
+
alpha: float = 0.3,
|
|
421
|
+
yaxis_name: str = "secondary",
|
|
422
|
+
) -> Any:
|
|
423
|
+
"""Fill area between two y-values on secondary y-axis."""
|
|
424
|
+
x_arr = x.values
|
|
425
|
+
if isinstance(y1, (int, float)):
|
|
426
|
+
y1_arr = [y1] * len(x_arr)
|
|
427
|
+
else:
|
|
428
|
+
y1_arr = y1.values if hasattr(y1, "values") else list(y1)
|
|
429
|
+
|
|
430
|
+
if isinstance(y2, (int, float)):
|
|
431
|
+
y2_arr = [y2] * len(x_arr)
|
|
432
|
+
else:
|
|
433
|
+
y2_arr = y2.values if hasattr(y2, "values") else list(y2)
|
|
434
|
+
|
|
435
|
+
return ax.varea(
|
|
436
|
+
x=x_arr,
|
|
437
|
+
y1=y1_arr,
|
|
438
|
+
y2=y2_arr,
|
|
439
|
+
fill_color=color,
|
|
440
|
+
fill_alpha=alpha,
|
|
441
|
+
y_range_name=yaxis_name,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def set_secondary_ylim(
|
|
445
|
+
self,
|
|
446
|
+
ax: figure,
|
|
447
|
+
bottom: float,
|
|
448
|
+
top: float,
|
|
449
|
+
yaxis_name: str = "secondary",
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Set secondary y-axis limits."""
|
|
452
|
+
if yaxis_name in ax.extra_y_ranges:
|
|
453
|
+
ax.extra_y_ranges[yaxis_name].start = bottom
|
|
454
|
+
ax.extra_y_ranges[yaxis_name].end = top
|
|
455
|
+
|
|
456
|
+
def set_secondary_ylabel(
|
|
457
|
+
self,
|
|
458
|
+
ax: figure,
|
|
459
|
+
label: str,
|
|
460
|
+
color: str = "black",
|
|
461
|
+
fontsize: int = 10,
|
|
462
|
+
yaxis_name: str = "secondary",
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Set secondary y-axis label."""
|
|
465
|
+
label = self._convert_label(label)
|
|
466
|
+
# Find the secondary axis and update its label
|
|
467
|
+
for renderer in ax.right:
|
|
468
|
+
if hasattr(renderer, "y_range_name") and renderer.y_range_name == yaxis_name:
|
|
469
|
+
renderer.axis_label = label
|
|
470
|
+
renderer.axis_label_text_font_size = f"{fontsize}pt"
|
|
471
|
+
renderer.axis_label_text_color = color
|
|
472
|
+
renderer.major_label_text_color = color
|
|
473
|
+
break
|
|
474
|
+
|
|
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.
|
|
482
|
+
|
|
483
|
+
Creates legend entries with dummy renderers that are excluded from
|
|
484
|
+
the data range calculation to avoid affecting axis scaling.
|
|
485
|
+
"""
|
|
486
|
+
from bokeh.models import ColumnDataSource, Legend, LegendItem, Range1d, Scatter
|
|
487
|
+
|
|
488
|
+
legend_items = []
|
|
489
|
+
|
|
490
|
+
# Create a separate range for legend glyphs that won't affect the main plot
|
|
491
|
+
if "legend_range" not in ax.extra_y_ranges:
|
|
492
|
+
ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
|
|
493
|
+
|
|
494
|
+
# Use coordinates within the legend range
|
|
495
|
+
dummy_source = ColumnDataSource(data={"x": [0], "y": [0]})
|
|
496
|
+
|
|
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]))
|
|
512
|
+
|
|
513
|
+
legend = Legend(
|
|
514
|
+
items=legend_items,
|
|
515
|
+
location="top_right",
|
|
516
|
+
title="r²",
|
|
517
|
+
background_fill_alpha=0.9,
|
|
518
|
+
border_line_color="black",
|
|
519
|
+
)
|
|
520
|
+
ax.add_layout(legend)
|
|
521
|
+
|
|
345
522
|
def add_legend(
|
|
346
523
|
self,
|
|
347
524
|
ax: figure,
|
|
@@ -370,26 +547,18 @@ class BokehBackend:
|
|
|
370
547
|
return ax.legend
|
|
371
548
|
|
|
372
549
|
def hide_spines(self, ax: figure, spines: List[str]) -> None:
|
|
373
|
-
"""Hide specified axis spines.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
ax.yaxis.visible = ax.yaxis.visible
|
|
550
|
+
"""Hide specified axis spines (no-op for Bokeh).
|
|
551
|
+
|
|
552
|
+
Bokeh doesn't have matplotlib-style spines. This method exists
|
|
553
|
+
for interface compatibility but has no visual effect.
|
|
554
|
+
"""
|
|
555
|
+
pass
|
|
380
556
|
|
|
381
557
|
def format_xaxis_mb(self, ax: figure) -> None:
|
|
382
558
|
"""Format x-axis to show megabase values."""
|
|
383
|
-
from bokeh.models import
|
|
384
|
-
|
|
385
|
-
ax.xaxis.formatter = NumeralTickFormatter(format="0.00")
|
|
386
|
-
ax.xaxis.axis_label = ax.xaxis.axis_label or "Position (Mb)"
|
|
387
|
-
|
|
388
|
-
# We need to scale values or use a custom formatter
|
|
389
|
-
# For now, assume values are already in bp and need /1e6
|
|
390
|
-
from bokeh.models import FuncTickFormatter
|
|
559
|
+
from bokeh.models import CustomJSTickFormatter
|
|
391
560
|
|
|
392
|
-
ax.xaxis.formatter =
|
|
561
|
+
ax.xaxis.formatter = CustomJSTickFormatter(
|
|
393
562
|
code="return (tick / 1e6).toFixed(2);"
|
|
394
563
|
)
|
|
395
564
|
|
|
@@ -9,7 +9,7 @@ import matplotlib.pyplot as plt
|
|
|
9
9
|
import pandas as pd
|
|
10
10
|
from matplotlib.axes import Axes
|
|
11
11
|
from matplotlib.figure import Figure
|
|
12
|
-
from matplotlib.patches import Rectangle
|
|
12
|
+
from matplotlib.patches import Polygon, Rectangle
|
|
13
13
|
from matplotlib.ticker import FuncFormatter, MaxNLocator
|
|
14
14
|
|
|
15
15
|
|
|
@@ -55,13 +55,8 @@ class MatplotlibBackend:
|
|
|
55
55
|
figsize=figsize,
|
|
56
56
|
height_ratios=height_ratios,
|
|
57
57
|
sharex=sharex,
|
|
58
|
-
gridspec_kw={"hspace": 0},
|
|
59
58
|
)
|
|
60
59
|
|
|
61
|
-
# Ensure axes is always a list
|
|
62
|
-
if n_panels == 1:
|
|
63
|
-
axes = [axes]
|
|
64
|
-
|
|
65
60
|
return fig, list(axes)
|
|
66
61
|
|
|
67
62
|
def scatter(
|
|
@@ -139,11 +134,17 @@ class MatplotlibBackend:
|
|
|
139
134
|
color: str = "grey",
|
|
140
135
|
linestyle: str = "--",
|
|
141
136
|
linewidth: float = 1.0,
|
|
137
|
+
alpha: float = 1.0,
|
|
142
138
|
zorder: int = 1,
|
|
143
139
|
) -> Any:
|
|
144
140
|
"""Add a horizontal line across the axes."""
|
|
145
141
|
return ax.axhline(
|
|
146
|
-
y=y,
|
|
142
|
+
y=y,
|
|
143
|
+
color=color,
|
|
144
|
+
linestyle=linestyle,
|
|
145
|
+
linewidth=linewidth,
|
|
146
|
+
alpha=alpha,
|
|
147
|
+
zorder=zorder,
|
|
147
148
|
)
|
|
148
149
|
|
|
149
150
|
def add_text(
|
|
@@ -187,6 +188,27 @@ class MatplotlibBackend:
|
|
|
187
188
|
ax.add_patch(rect)
|
|
188
189
|
return rect
|
|
189
190
|
|
|
191
|
+
def add_polygon(
|
|
192
|
+
self,
|
|
193
|
+
ax: Axes,
|
|
194
|
+
points: List[List[float]],
|
|
195
|
+
facecolor: str = "blue",
|
|
196
|
+
edgecolor: str = "black",
|
|
197
|
+
linewidth: float = 0.5,
|
|
198
|
+
zorder: int = 2,
|
|
199
|
+
) -> Any:
|
|
200
|
+
"""Add a polygon patch to axes."""
|
|
201
|
+
polygon = Polygon(
|
|
202
|
+
points,
|
|
203
|
+
closed=True,
|
|
204
|
+
facecolor=facecolor,
|
|
205
|
+
edgecolor=edgecolor,
|
|
206
|
+
linewidth=linewidth,
|
|
207
|
+
zorder=zorder,
|
|
208
|
+
)
|
|
209
|
+
ax.add_patch(polygon)
|
|
210
|
+
return polygon
|
|
211
|
+
|
|
190
212
|
def set_xlim(self, ax: Axes, left: float, right: float) -> None:
|
|
191
213
|
"""Set x-axis limits."""
|
|
192
214
|
ax.set_xlim(left, right)
|
|
@@ -205,7 +227,12 @@ class MatplotlibBackend:
|
|
|
205
227
|
|
|
206
228
|
def set_title(self, ax: Axes, title: str, fontsize: int = 14) -> None:
|
|
207
229
|
"""Set panel title."""
|
|
208
|
-
ax.set_title(
|
|
230
|
+
ax.set_title(
|
|
231
|
+
title,
|
|
232
|
+
fontsize=fontsize,
|
|
233
|
+
fontweight="bold",
|
|
234
|
+
fontfamily="sans-serif",
|
|
235
|
+
)
|
|
209
236
|
|
|
210
237
|
def create_twin_axis(self, ax: Axes) -> Axes:
|
|
211
238
|
"""Create a secondary y-axis sharing the same x-axis."""
|