pylocuszoom 0.2.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 +52 -1
- pylocuszoom/backends/base.py +47 -0
- pylocuszoom/backends/bokeh_backend.py +323 -61
- pylocuszoom/backends/matplotlib_backend.py +133 -7
- pylocuszoom/backends/plotly_backend.py +423 -33
- pylocuszoom/colors.py +3 -1
- pylocuszoom/finemapping.py +0 -1
- pylocuszoom/gene_track.py +232 -23
- pylocuszoom/loaders.py +862 -0
- pylocuszoom/plotter.py +354 -245
- pylocuszoom/py.typed +0 -0
- pylocuszoom/recombination.py +4 -4
- pylocuszoom/schemas.py +395 -0
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.5.0.dist-info}/METADATA +125 -31
- pylocuszoom-0.5.0.dist-info/RECORD +24 -0
- pylocuszoom-0.2.0.dist-info/RECORD +0 -21
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.5.0.dist-info}/WHEEL +0 -0
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.5.0.dist-info}/licenses/LICENSE.md +0 -0
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.
|
|
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
|
]
|
pylocuszoom/backends/base.py
CHANGED
|
@@ -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:
|
|
@@ -339,3 +341,48 @@ class PlotBackend(Protocol):
|
|
|
339
341
|
fig: Figure object.
|
|
340
342
|
"""
|
|
341
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
|
+
...
|
|
@@ -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, HoverTool, Span
|
|
11
|
+
from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
|
|
12
12
|
from bokeh.plotting import figure
|
|
13
13
|
|
|
14
14
|
|
|
@@ -56,29 +56,29 @@ 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
|
-
#
|
|
71
|
-
|
|
72
|
-
x_range = p.x_range
|
|
73
|
-
|
|
74
|
-
# Style
|
|
75
|
-
p.grid.grid_line_alpha = 0.3
|
|
70
|
+
# Style - no grid lines, black axes for clean LocusZoom appearance
|
|
71
|
+
p.grid.visible = False
|
|
76
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
|
|
77
77
|
|
|
78
78
|
figures.append(p)
|
|
79
79
|
|
|
80
|
-
# Create column layout
|
|
81
|
-
layout = column(*figures
|
|
80
|
+
# Create column layout (use default sizing mode to avoid validation warnings)
|
|
81
|
+
layout = column(*figures)
|
|
82
82
|
|
|
83
83
|
return layout, figures
|
|
84
84
|
|
|
@@ -118,29 +118,34 @@ class BokehBackend:
|
|
|
118
118
|
if hover_data is not None:
|
|
119
119
|
for col in hover_data.columns:
|
|
120
120
|
data[col] = hover_data[col].values
|
|
121
|
-
|
|
121
|
+
col_lower = col.lower()
|
|
122
|
+
if col_lower in ("p-value", "pval", "p_value"):
|
|
122
123
|
tooltips.append((col, "@{" + col + "}{0.2e}"))
|
|
123
|
-
elif
|
|
124
|
+
elif any(x in col_lower for x in ("r2", "r²", "ld")):
|
|
124
125
|
tooltips.append((col, "@{" + col + "}{0.3f}"))
|
|
126
|
+
elif "pos" in col_lower:
|
|
127
|
+
tooltips.append((col, "@{" + col + "}{0,0}"))
|
|
125
128
|
else:
|
|
126
129
|
tooltips.append((col, f"@{col}"))
|
|
127
130
|
|
|
128
131
|
source = ColumnDataSource(data)
|
|
129
132
|
|
|
130
|
-
# Get marker type
|
|
133
|
+
# Get marker type for scatter()
|
|
131
134
|
marker_type = self._marker_map.get(marker, "circle")
|
|
132
135
|
|
|
133
|
-
# Create scatter
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
# Create scatter using scatter() method (Bokeh 3.4+ preferred API)
|
|
137
|
+
scatter_kwargs = {
|
|
138
|
+
"source": source,
|
|
139
|
+
"marker": marker_type,
|
|
140
|
+
"size": "size",
|
|
141
|
+
"fill_color": "color",
|
|
142
|
+
"line_color": edgecolor,
|
|
143
|
+
"line_width": linewidth,
|
|
144
|
+
}
|
|
145
|
+
if label:
|
|
146
|
+
scatter_kwargs["legend_label"] = label
|
|
147
|
+
|
|
148
|
+
renderer = ax.scatter("x", "y", **scatter_kwargs)
|
|
144
149
|
|
|
145
150
|
# Add hover tool if we have hover data
|
|
146
151
|
if tooltips:
|
|
@@ -175,15 +180,16 @@ class BokehBackend:
|
|
|
175
180
|
}
|
|
176
181
|
line_dash = dash_map.get(linestyle, "solid")
|
|
177
182
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
legend_label=
|
|
186
|
-
|
|
183
|
+
line_kwargs = {
|
|
184
|
+
"line_color": color,
|
|
185
|
+
"line_width": linewidth,
|
|
186
|
+
"line_alpha": alpha,
|
|
187
|
+
"line_dash": line_dash,
|
|
188
|
+
}
|
|
189
|
+
if label:
|
|
190
|
+
line_kwargs["legend_label"] = label
|
|
191
|
+
|
|
192
|
+
return ax.line(x.values, y.values, **line_kwargs)
|
|
187
193
|
|
|
188
194
|
def fill_between(
|
|
189
195
|
self,
|
|
@@ -223,6 +229,7 @@ class BokehBackend:
|
|
|
223
229
|
color: str = "grey",
|
|
224
230
|
linestyle: str = "--",
|
|
225
231
|
linewidth: float = 1.0,
|
|
232
|
+
alpha: float = 1.0,
|
|
226
233
|
zorder: int = 1,
|
|
227
234
|
) -> Any:
|
|
228
235
|
"""Add a horizontal line across the figure."""
|
|
@@ -235,6 +242,7 @@ class BokehBackend:
|
|
|
235
242
|
line_color=color,
|
|
236
243
|
line_dash=line_dash,
|
|
237
244
|
line_width=linewidth,
|
|
245
|
+
line_alpha=alpha,
|
|
238
246
|
)
|
|
239
247
|
ax.add_layout(span)
|
|
240
248
|
return span
|
|
@@ -303,6 +311,28 @@ class BokehBackend:
|
|
|
303
311
|
line_width=linewidth,
|
|
304
312
|
)
|
|
305
313
|
|
|
314
|
+
def add_polygon(
|
|
315
|
+
self,
|
|
316
|
+
ax: figure,
|
|
317
|
+
points: List[List[float]],
|
|
318
|
+
facecolor: str = "blue",
|
|
319
|
+
edgecolor: str = "black",
|
|
320
|
+
linewidth: float = 0.5,
|
|
321
|
+
zorder: int = 2,
|
|
322
|
+
) -> Any:
|
|
323
|
+
"""Add a polygon (e.g., triangle for strand arrows) to the figure."""
|
|
324
|
+
xs = [p[0] for p in points]
|
|
325
|
+
ys = [p[1] for p in points]
|
|
326
|
+
|
|
327
|
+
# Bokeh patch() uses x/y (singular) for single polygon
|
|
328
|
+
return ax.patch(
|
|
329
|
+
x=xs,
|
|
330
|
+
y=ys,
|
|
331
|
+
fill_color=facecolor,
|
|
332
|
+
line_color=edgecolor,
|
|
333
|
+
line_width=linewidth,
|
|
334
|
+
)
|
|
335
|
+
|
|
306
336
|
def set_xlim(self, ax: figure, left: float, right: float) -> None:
|
|
307
337
|
"""Set x-axis limits."""
|
|
308
338
|
ax.x_range.start = left
|
|
@@ -315,14 +345,41 @@ class BokehBackend:
|
|
|
315
345
|
|
|
316
346
|
def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
317
347
|
"""Set x-axis label."""
|
|
348
|
+
label = self._convert_label(label)
|
|
318
349
|
ax.xaxis.axis_label = label
|
|
319
350
|
ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
320
351
|
|
|
321
352
|
def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
322
353
|
"""Set y-axis label."""
|
|
354
|
+
label = self._convert_label(label)
|
|
323
355
|
ax.yaxis.axis_label = label
|
|
324
356
|
ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
325
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
|
+
|
|
368
|
+
def _convert_label(self, label: str) -> str:
|
|
369
|
+
"""Convert LaTeX-style labels to Unicode for Bokeh display."""
|
|
370
|
+
conversions = [
|
|
371
|
+
(r"$-\log_{10}$ P", "-log₁₀(P)"),
|
|
372
|
+
(r"$-\log_{10}$", "-log₁₀"),
|
|
373
|
+
(r"\log_{10}", "log₁₀"),
|
|
374
|
+
(r"$r^2$", "r²"),
|
|
375
|
+
(r"$R^2$", "R²"),
|
|
376
|
+
]
|
|
377
|
+
for latex, unicode_str in conversions:
|
|
378
|
+
if latex in label:
|
|
379
|
+
label = label.replace(latex, unicode_str)
|
|
380
|
+
label = label.replace("$", "")
|
|
381
|
+
return label
|
|
382
|
+
|
|
326
383
|
def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
|
|
327
384
|
"""Set figure title."""
|
|
328
385
|
ax.title.text = title
|
|
@@ -341,6 +398,171 @@ class BokehBackend:
|
|
|
341
398
|
|
|
342
399
|
return "secondary"
|
|
343
400
|
|
|
401
|
+
def line_secondary(
|
|
402
|
+
self,
|
|
403
|
+
ax: figure,
|
|
404
|
+
x: pd.Series,
|
|
405
|
+
y: pd.Series,
|
|
406
|
+
color: str = "blue",
|
|
407
|
+
linewidth: float = 1.5,
|
|
408
|
+
alpha: float = 1.0,
|
|
409
|
+
linestyle: str = "-",
|
|
410
|
+
label: Optional[str] = None,
|
|
411
|
+
yaxis_name: str = "secondary",
|
|
412
|
+
) -> Any:
|
|
413
|
+
"""Create a line plot on secondary y-axis."""
|
|
414
|
+
dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
|
|
415
|
+
line_dash = dash_map.get(linestyle, "solid")
|
|
416
|
+
|
|
417
|
+
return ax.line(
|
|
418
|
+
x.values,
|
|
419
|
+
y.values,
|
|
420
|
+
line_color=color,
|
|
421
|
+
line_width=linewidth,
|
|
422
|
+
line_alpha=alpha,
|
|
423
|
+
line_dash=line_dash,
|
|
424
|
+
y_range_name=yaxis_name,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def fill_between_secondary(
|
|
428
|
+
self,
|
|
429
|
+
ax: figure,
|
|
430
|
+
x: pd.Series,
|
|
431
|
+
y1: Union[float, pd.Series],
|
|
432
|
+
y2: Union[float, pd.Series],
|
|
433
|
+
color: str = "blue",
|
|
434
|
+
alpha: float = 0.3,
|
|
435
|
+
yaxis_name: str = "secondary",
|
|
436
|
+
) -> Any:
|
|
437
|
+
"""Fill area between two y-values on secondary y-axis."""
|
|
438
|
+
x_arr = x.values
|
|
439
|
+
if isinstance(y1, (int, float)):
|
|
440
|
+
y1_arr = [y1] * len(x_arr)
|
|
441
|
+
else:
|
|
442
|
+
y1_arr = y1.values if hasattr(y1, "values") else list(y1)
|
|
443
|
+
|
|
444
|
+
if isinstance(y2, (int, float)):
|
|
445
|
+
y2_arr = [y2] * len(x_arr)
|
|
446
|
+
else:
|
|
447
|
+
y2_arr = y2.values if hasattr(y2, "values") else list(y2)
|
|
448
|
+
|
|
449
|
+
return ax.varea(
|
|
450
|
+
x=x_arr,
|
|
451
|
+
y1=y1_arr,
|
|
452
|
+
y2=y2_arr,
|
|
453
|
+
fill_color=color,
|
|
454
|
+
fill_alpha=alpha,
|
|
455
|
+
y_range_name=yaxis_name,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def set_secondary_ylim(
|
|
459
|
+
self,
|
|
460
|
+
ax: figure,
|
|
461
|
+
bottom: float,
|
|
462
|
+
top: float,
|
|
463
|
+
yaxis_name: str = "secondary",
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Set secondary y-axis limits."""
|
|
466
|
+
if yaxis_name in ax.extra_y_ranges:
|
|
467
|
+
ax.extra_y_ranges[yaxis_name].start = bottom
|
|
468
|
+
ax.extra_y_ranges[yaxis_name].end = top
|
|
469
|
+
|
|
470
|
+
def set_secondary_ylabel(
|
|
471
|
+
self,
|
|
472
|
+
ax: figure,
|
|
473
|
+
label: str,
|
|
474
|
+
color: str = "black",
|
|
475
|
+
fontsize: int = 10,
|
|
476
|
+
yaxis_name: str = "secondary",
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Set secondary y-axis label."""
|
|
479
|
+
label = self._convert_label(label)
|
|
480
|
+
# Find the secondary axis and update its label
|
|
481
|
+
for renderer in ax.right:
|
|
482
|
+
if (
|
|
483
|
+
hasattr(renderer, "y_range_name")
|
|
484
|
+
and renderer.y_range_name == yaxis_name
|
|
485
|
+
):
|
|
486
|
+
renderer.axis_label = label
|
|
487
|
+
renderer.axis_label_text_font_size = f"{fontsize}pt"
|
|
488
|
+
renderer.axis_label_text_color = color
|
|
489
|
+
renderer.major_label_text_color = color
|
|
490
|
+
break
|
|
491
|
+
|
|
492
|
+
def _ensure_legend_range(self, ax: figure) -> Any:
|
|
493
|
+
"""Ensure legend range exists and return a dummy data source.
|
|
494
|
+
|
|
495
|
+
Creates a separate y-range for legend glyphs so they don't affect
|
|
496
|
+
the main plot's axis scaling.
|
|
497
|
+
"""
|
|
498
|
+
from bokeh.models import ColumnDataSource, Range1d
|
|
499
|
+
|
|
500
|
+
if "legend_range" not in ax.extra_y_ranges:
|
|
501
|
+
ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
|
|
502
|
+
return ColumnDataSource(data={"x": [0], "y": [0]})
|
|
503
|
+
|
|
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])
|
|
529
|
+
|
|
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
|
|
533
|
+
|
|
534
|
+
legend = Legend(
|
|
535
|
+
items=items,
|
|
536
|
+
location="top_right",
|
|
537
|
+
title=title,
|
|
538
|
+
background_fill_alpha=0.9,
|
|
539
|
+
border_line_color="black",
|
|
540
|
+
spacing=0,
|
|
541
|
+
padding=4,
|
|
542
|
+
label_height=12,
|
|
543
|
+
glyph_height=12,
|
|
544
|
+
)
|
|
545
|
+
ax.add_layout(legend)
|
|
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
|
+
|
|
344
566
|
def add_legend(
|
|
345
567
|
self,
|
|
346
568
|
ax: figure,
|
|
@@ -350,17 +572,7 @@ class BokehBackend:
|
|
|
350
572
|
title: Optional[str] = None,
|
|
351
573
|
) -> Any:
|
|
352
574
|
"""Configure legend on the figure."""
|
|
353
|
-
|
|
354
|
-
# Just configure position
|
|
355
|
-
|
|
356
|
-
loc_map = {
|
|
357
|
-
"upper left": "top_left",
|
|
358
|
-
"upper right": "top_right",
|
|
359
|
-
"lower left": "bottom_left",
|
|
360
|
-
"lower right": "bottom_right",
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
ax.legend.location = loc_map.get(loc, "top_left")
|
|
575
|
+
ax.legend.location = self._get_legend_location(loc, "top_left")
|
|
364
576
|
if title:
|
|
365
577
|
ax.legend.title = title
|
|
366
578
|
ax.legend.background_fill_alpha = 0.9
|
|
@@ -369,26 +581,25 @@ class BokehBackend:
|
|
|
369
581
|
return ax.legend
|
|
370
582
|
|
|
371
583
|
def hide_spines(self, ax: figure, spines: List[str]) -> None:
|
|
372
|
-
"""Hide specified axis spines.
|
|
373
|
-
# Bokeh doesn't have spines in the same way
|
|
374
|
-
# We can hide axis lines
|
|
375
|
-
if "top" in spines:
|
|
376
|
-
ax.xaxis.visible = ax.xaxis.visible # Keep visible but could customize
|
|
377
|
-
if "right" in spines:
|
|
378
|
-
ax.yaxis.visible = ax.yaxis.visible
|
|
584
|
+
"""Hide specified axis spines (no-op for Bokeh).
|
|
379
585
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
586
|
+
Bokeh doesn't have matplotlib-style spines. This method exists
|
|
587
|
+
for interface compatibility but has no visual effect.
|
|
588
|
+
"""
|
|
589
|
+
pass
|
|
383
590
|
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
386
595
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
from bokeh.models import
|
|
596
|
+
def format_xaxis_mb(self, ax: figure) -> None:
|
|
597
|
+
"""Format x-axis to show megabase values."""
|
|
598
|
+
from bokeh.models import CustomJSTickFormatter
|
|
390
599
|
|
|
391
|
-
ax.xaxis.formatter =
|
|
600
|
+
ax.xaxis.formatter = CustomJSTickFormatter(
|
|
601
|
+
code="return (tick / 1e6).toFixed(2);"
|
|
602
|
+
)
|
|
392
603
|
|
|
393
604
|
def save(
|
|
394
605
|
self,
|
|
@@ -421,6 +632,57 @@ class BokehBackend:
|
|
|
421
632
|
"""Close the figure (no-op for bokeh)."""
|
|
422
633
|
pass
|
|
423
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
|
+
|
|
424
686
|
def finalize_layout(
|
|
425
687
|
self,
|
|
426
688
|
fig: Any,
|