pylocuszoom 0.1.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 +120 -0
- pylocuszoom/backends/__init__.py +52 -0
- pylocuszoom/backends/base.py +341 -0
- pylocuszoom/backends/bokeh_backend.py +441 -0
- pylocuszoom/backends/matplotlib_backend.py +288 -0
- pylocuszoom/backends/plotly_backend.py +474 -0
- pylocuszoom/colors.py +107 -0
- pylocuszoom/eqtl.py +218 -0
- pylocuszoom/gene_track.py +311 -0
- pylocuszoom/labels.py +118 -0
- pylocuszoom/ld.py +209 -0
- pylocuszoom/logging.py +153 -0
- pylocuszoom/plotter.py +733 -0
- pylocuszoom/recombination.py +432 -0
- pylocuszoom/reference_data/__init__.py +4 -0
- pylocuszoom/utils.py +194 -0
- pylocuszoom-0.1.0.dist-info/METADATA +367 -0
- pylocuszoom-0.1.0.dist-info/RECORD +20 -0
- pylocuszoom-0.1.0.dist-info/WHEEL +4 -0
- pylocuszoom-0.1.0.dist-info/licenses/LICENSE.md +17 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Bokeh backend for pyLocusZoom.
|
|
2
|
+
|
|
3
|
+
Interactive backend with hover tooltips, well-suited for dashboards.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from bokeh.io import export_png, export_svgs, output_file, save, show
|
|
10
|
+
from bokeh.layouts import column
|
|
11
|
+
from bokeh.models import ColumnDataSource, HoverTool, Legend, LegendItem, Span
|
|
12
|
+
from bokeh.plotting import figure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BokehBackend:
|
|
16
|
+
"""Bokeh backend for interactive plot generation.
|
|
17
|
+
|
|
18
|
+
Produces interactive HTML plots suitable for embedding in web
|
|
19
|
+
applications and dashboards.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
"""Initialize the bokeh backend."""
|
|
24
|
+
self._marker_map = {
|
|
25
|
+
"o": "circle",
|
|
26
|
+
"D": "diamond",
|
|
27
|
+
"s": "square",
|
|
28
|
+
"^": "triangle",
|
|
29
|
+
"v": "inverted_triangle",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def create_figure(
|
|
33
|
+
self,
|
|
34
|
+
n_panels: int,
|
|
35
|
+
height_ratios: List[float],
|
|
36
|
+
figsize: Tuple[float, float],
|
|
37
|
+
sharex: bool = True,
|
|
38
|
+
) -> Tuple[Any, List[figure]]:
|
|
39
|
+
"""Create a layout with multiple panels.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
n_panels: Number of vertical panels.
|
|
43
|
+
height_ratios: Relative heights for each panel.
|
|
44
|
+
figsize: Figure size as (width, height) in inches.
|
|
45
|
+
sharex: Whether panels share the x-axis.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (layout, list of figure objects).
|
|
49
|
+
"""
|
|
50
|
+
# Convert inches to pixels
|
|
51
|
+
width_px = int(figsize[0] * 100)
|
|
52
|
+
total_height = int(figsize[1] * 100)
|
|
53
|
+
|
|
54
|
+
# Calculate individual heights
|
|
55
|
+
total_ratio = sum(height_ratios)
|
|
56
|
+
heights = [int(total_height * r / total_ratio) for r in height_ratios]
|
|
57
|
+
|
|
58
|
+
figures = []
|
|
59
|
+
x_range = None
|
|
60
|
+
|
|
61
|
+
for i, h in enumerate(heights):
|
|
62
|
+
p = figure(
|
|
63
|
+
width=width_px,
|
|
64
|
+
height=h,
|
|
65
|
+
x_range=x_range if sharex and x_range else None,
|
|
66
|
+
tools="pan,wheel_zoom,box_zoom,reset,save",
|
|
67
|
+
toolbar_location="above" if i == 0 else None,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Share x_range for subsequent figures
|
|
71
|
+
if sharex and x_range is None:
|
|
72
|
+
x_range = p.x_range
|
|
73
|
+
|
|
74
|
+
# Style
|
|
75
|
+
p.grid.grid_line_alpha = 0.3
|
|
76
|
+
p.outline_line_color = None
|
|
77
|
+
|
|
78
|
+
figures.append(p)
|
|
79
|
+
|
|
80
|
+
# Create column layout
|
|
81
|
+
layout = column(*figures, sizing_mode="fixed")
|
|
82
|
+
|
|
83
|
+
return layout, figures
|
|
84
|
+
|
|
85
|
+
def scatter(
|
|
86
|
+
self,
|
|
87
|
+
ax: figure,
|
|
88
|
+
x: pd.Series,
|
|
89
|
+
y: pd.Series,
|
|
90
|
+
colors: Union[str, List[str], pd.Series],
|
|
91
|
+
sizes: Union[float, List[float], pd.Series] = 60,
|
|
92
|
+
marker: str = "o",
|
|
93
|
+
edgecolor: str = "black",
|
|
94
|
+
linewidth: float = 0.5,
|
|
95
|
+
zorder: int = 2,
|
|
96
|
+
hover_data: Optional[pd.DataFrame] = None,
|
|
97
|
+
label: Optional[str] = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Create a scatter plot on the given figure."""
|
|
100
|
+
# Prepare data source
|
|
101
|
+
data = {"x": x.values, "y": y.values}
|
|
102
|
+
|
|
103
|
+
# Handle colors
|
|
104
|
+
if isinstance(colors, str):
|
|
105
|
+
data["color"] = [colors] * len(x)
|
|
106
|
+
else:
|
|
107
|
+
data["color"] = list(colors) if hasattr(colors, "tolist") else colors
|
|
108
|
+
|
|
109
|
+
# Handle sizes (convert from area to diameter)
|
|
110
|
+
if isinstance(sizes, (int, float)):
|
|
111
|
+
bokeh_size = max(6, sizes ** 0.5)
|
|
112
|
+
data["size"] = [bokeh_size] * len(x)
|
|
113
|
+
else:
|
|
114
|
+
data["size"] = [max(6, s ** 0.5) for s in sizes]
|
|
115
|
+
|
|
116
|
+
# Add hover data
|
|
117
|
+
tooltips = []
|
|
118
|
+
if hover_data is not None:
|
|
119
|
+
for col in hover_data.columns:
|
|
120
|
+
data[col] = hover_data[col].values
|
|
121
|
+
if "p" in col.lower():
|
|
122
|
+
tooltips.append((col, "@{" + col + "}{0.2e}"))
|
|
123
|
+
elif "r2" in col.lower() or "ld" in col.lower():
|
|
124
|
+
tooltips.append((col, "@{" + col + "}{0.3f}"))
|
|
125
|
+
else:
|
|
126
|
+
tooltips.append((col, f"@{col}"))
|
|
127
|
+
|
|
128
|
+
source = ColumnDataSource(data)
|
|
129
|
+
|
|
130
|
+
# Get marker type
|
|
131
|
+
marker_type = self._marker_map.get(marker, "circle")
|
|
132
|
+
|
|
133
|
+
# Create scatter
|
|
134
|
+
renderer = getattr(ax, marker_type)(
|
|
135
|
+
"x",
|
|
136
|
+
"y",
|
|
137
|
+
source=source,
|
|
138
|
+
size="size",
|
|
139
|
+
fill_color="color",
|
|
140
|
+
line_color=edgecolor,
|
|
141
|
+
line_width=linewidth,
|
|
142
|
+
legend_label=label if label else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Add hover tool if we have hover data
|
|
146
|
+
if tooltips:
|
|
147
|
+
hover = HoverTool(
|
|
148
|
+
tooltips=tooltips,
|
|
149
|
+
renderers=[renderer],
|
|
150
|
+
mode="mouse",
|
|
151
|
+
)
|
|
152
|
+
ax.add_tools(hover)
|
|
153
|
+
|
|
154
|
+
return renderer
|
|
155
|
+
|
|
156
|
+
def line(
|
|
157
|
+
self,
|
|
158
|
+
ax: figure,
|
|
159
|
+
x: pd.Series,
|
|
160
|
+
y: pd.Series,
|
|
161
|
+
color: str = "blue",
|
|
162
|
+
linewidth: float = 1.5,
|
|
163
|
+
alpha: float = 1.0,
|
|
164
|
+
linestyle: str = "-",
|
|
165
|
+
zorder: int = 1,
|
|
166
|
+
label: Optional[str] = None,
|
|
167
|
+
) -> Any:
|
|
168
|
+
"""Create a line plot on the given figure."""
|
|
169
|
+
# Convert linestyle
|
|
170
|
+
dash_map = {
|
|
171
|
+
"-": "solid",
|
|
172
|
+
"--": "dashed",
|
|
173
|
+
":": "dotted",
|
|
174
|
+
"-.": "dashdot",
|
|
175
|
+
}
|
|
176
|
+
line_dash = dash_map.get(linestyle, "solid")
|
|
177
|
+
|
|
178
|
+
return ax.line(
|
|
179
|
+
x.values,
|
|
180
|
+
y.values,
|
|
181
|
+
line_color=color,
|
|
182
|
+
line_width=linewidth,
|
|
183
|
+
line_alpha=alpha,
|
|
184
|
+
line_dash=line_dash,
|
|
185
|
+
legend_label=label if label else None,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def fill_between(
|
|
189
|
+
self,
|
|
190
|
+
ax: figure,
|
|
191
|
+
x: pd.Series,
|
|
192
|
+
y1: Union[float, pd.Series],
|
|
193
|
+
y2: Union[float, pd.Series],
|
|
194
|
+
color: str = "blue",
|
|
195
|
+
alpha: float = 0.3,
|
|
196
|
+
zorder: int = 0,
|
|
197
|
+
) -> Any:
|
|
198
|
+
"""Fill area between two y-values."""
|
|
199
|
+
# Convert to arrays
|
|
200
|
+
x_arr = x.values
|
|
201
|
+
if isinstance(y1, (int, float)):
|
|
202
|
+
y1_arr = [y1] * len(x_arr)
|
|
203
|
+
else:
|
|
204
|
+
y1_arr = y1.values if hasattr(y1, "values") else list(y1)
|
|
205
|
+
|
|
206
|
+
if isinstance(y2, (int, float)):
|
|
207
|
+
y2_arr = [y2] * len(x_arr)
|
|
208
|
+
else:
|
|
209
|
+
y2_arr = y2.values if hasattr(y2, "values") else list(y2)
|
|
210
|
+
|
|
211
|
+
return ax.varea(
|
|
212
|
+
x=x_arr,
|
|
213
|
+
y1=y1_arr,
|
|
214
|
+
y2=y2_arr,
|
|
215
|
+
fill_color=color,
|
|
216
|
+
fill_alpha=alpha,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def axhline(
|
|
220
|
+
self,
|
|
221
|
+
ax: figure,
|
|
222
|
+
y: float,
|
|
223
|
+
color: str = "grey",
|
|
224
|
+
linestyle: str = "--",
|
|
225
|
+
linewidth: float = 1.0,
|
|
226
|
+
zorder: int = 1,
|
|
227
|
+
) -> Any:
|
|
228
|
+
"""Add a horizontal line across the figure."""
|
|
229
|
+
dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
|
|
230
|
+
line_dash = dash_map.get(linestyle, "dashed")
|
|
231
|
+
|
|
232
|
+
span = Span(
|
|
233
|
+
location=y,
|
|
234
|
+
dimension="width",
|
|
235
|
+
line_color=color,
|
|
236
|
+
line_dash=line_dash,
|
|
237
|
+
line_width=linewidth,
|
|
238
|
+
)
|
|
239
|
+
ax.add_layout(span)
|
|
240
|
+
return span
|
|
241
|
+
|
|
242
|
+
def add_text(
|
|
243
|
+
self,
|
|
244
|
+
ax: figure,
|
|
245
|
+
x: float,
|
|
246
|
+
y: float,
|
|
247
|
+
text: str,
|
|
248
|
+
fontsize: int = 10,
|
|
249
|
+
ha: str = "center",
|
|
250
|
+
va: str = "bottom",
|
|
251
|
+
rotation: float = 0,
|
|
252
|
+
color: str = "black",
|
|
253
|
+
) -> Any:
|
|
254
|
+
"""Add text annotation to figure."""
|
|
255
|
+
from bokeh.models import Label
|
|
256
|
+
|
|
257
|
+
# Map alignment
|
|
258
|
+
anchor_map = {
|
|
259
|
+
("center", "bottom"): ("center", "bottom"),
|
|
260
|
+
("center", "top"): ("center", "top"),
|
|
261
|
+
("left", "bottom"): ("left", "bottom"),
|
|
262
|
+
("right", "bottom"): ("right", "bottom"),
|
|
263
|
+
}
|
|
264
|
+
text_align, text_baseline = anchor_map.get((ha, va), ("center", "bottom"))
|
|
265
|
+
|
|
266
|
+
label = Label(
|
|
267
|
+
x=x,
|
|
268
|
+
y=y,
|
|
269
|
+
text=text,
|
|
270
|
+
text_font_size=f"{fontsize}pt",
|
|
271
|
+
text_color=color,
|
|
272
|
+
text_align=text_align,
|
|
273
|
+
text_baseline=text_baseline,
|
|
274
|
+
angle=rotation,
|
|
275
|
+
angle_units="deg",
|
|
276
|
+
)
|
|
277
|
+
ax.add_layout(label)
|
|
278
|
+
return label
|
|
279
|
+
|
|
280
|
+
def add_rectangle(
|
|
281
|
+
self,
|
|
282
|
+
ax: figure,
|
|
283
|
+
xy: Tuple[float, float],
|
|
284
|
+
width: float,
|
|
285
|
+
height: float,
|
|
286
|
+
facecolor: str = "blue",
|
|
287
|
+
edgecolor: str = "black",
|
|
288
|
+
linewidth: float = 0.5,
|
|
289
|
+
zorder: int = 2,
|
|
290
|
+
) -> Any:
|
|
291
|
+
"""Add a rectangle to the figure."""
|
|
292
|
+
from bokeh.models import Rect
|
|
293
|
+
|
|
294
|
+
x_center = xy[0] + width / 2
|
|
295
|
+
y_center = xy[1] + height / 2
|
|
296
|
+
|
|
297
|
+
return ax.rect(
|
|
298
|
+
x=[x_center],
|
|
299
|
+
y=[y_center],
|
|
300
|
+
width=[width],
|
|
301
|
+
height=[height],
|
|
302
|
+
fill_color=facecolor,
|
|
303
|
+
line_color=edgecolor,
|
|
304
|
+
line_width=linewidth,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def set_xlim(self, ax: figure, left: float, right: float) -> None:
|
|
308
|
+
"""Set x-axis limits."""
|
|
309
|
+
ax.x_range.start = left
|
|
310
|
+
ax.x_range.end = right
|
|
311
|
+
|
|
312
|
+
def set_ylim(self, ax: figure, bottom: float, top: float) -> None:
|
|
313
|
+
"""Set y-axis limits."""
|
|
314
|
+
ax.y_range.start = bottom
|
|
315
|
+
ax.y_range.end = top
|
|
316
|
+
|
|
317
|
+
def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
318
|
+
"""Set x-axis label."""
|
|
319
|
+
ax.xaxis.axis_label = label
|
|
320
|
+
ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
321
|
+
|
|
322
|
+
def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
323
|
+
"""Set y-axis label."""
|
|
324
|
+
ax.yaxis.axis_label = label
|
|
325
|
+
ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
326
|
+
|
|
327
|
+
def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
|
|
328
|
+
"""Set figure title."""
|
|
329
|
+
ax.title.text = title
|
|
330
|
+
ax.title.text_font_size = f"{fontsize}pt"
|
|
331
|
+
|
|
332
|
+
def create_twin_axis(self, ax: figure) -> Any:
|
|
333
|
+
"""Create a secondary y-axis.
|
|
334
|
+
|
|
335
|
+
Returns a dict with configuration for extra_y_ranges.
|
|
336
|
+
"""
|
|
337
|
+
from bokeh.models import LinearAxis, Range1d
|
|
338
|
+
|
|
339
|
+
# Add a second y-axis
|
|
340
|
+
ax.extra_y_ranges = {"secondary": Range1d(start=0, end=100)}
|
|
341
|
+
ax.add_layout(LinearAxis(y_range_name="secondary"), "right")
|
|
342
|
+
|
|
343
|
+
return "secondary"
|
|
344
|
+
|
|
345
|
+
def add_legend(
|
|
346
|
+
self,
|
|
347
|
+
ax: figure,
|
|
348
|
+
handles: List[Any],
|
|
349
|
+
labels: List[str],
|
|
350
|
+
loc: str = "upper left",
|
|
351
|
+
title: Optional[str] = None,
|
|
352
|
+
) -> Any:
|
|
353
|
+
"""Configure legend on the figure."""
|
|
354
|
+
# Bokeh handles legend automatically from legend_label
|
|
355
|
+
# Just configure position
|
|
356
|
+
|
|
357
|
+
loc_map = {
|
|
358
|
+
"upper left": "top_left",
|
|
359
|
+
"upper right": "top_right",
|
|
360
|
+
"lower left": "bottom_left",
|
|
361
|
+
"lower right": "bottom_right",
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
ax.legend.location = loc_map.get(loc, "top_left")
|
|
365
|
+
if title:
|
|
366
|
+
ax.legend.title = title
|
|
367
|
+
ax.legend.background_fill_alpha = 0.9
|
|
368
|
+
ax.legend.border_line_color = "black"
|
|
369
|
+
|
|
370
|
+
return ax.legend
|
|
371
|
+
|
|
372
|
+
def hide_spines(self, ax: figure, spines: List[str]) -> None:
|
|
373
|
+
"""Hide specified axis spines."""
|
|
374
|
+
# Bokeh doesn't have spines in the same way
|
|
375
|
+
# We can hide axis lines
|
|
376
|
+
if "top" in spines:
|
|
377
|
+
ax.xaxis.visible = ax.xaxis.visible # Keep visible but could customize
|
|
378
|
+
if "right" in spines:
|
|
379
|
+
ax.yaxis.visible = ax.yaxis.visible
|
|
380
|
+
|
|
381
|
+
def format_xaxis_mb(self, ax: figure) -> None:
|
|
382
|
+
"""Format x-axis to show megabase values."""
|
|
383
|
+
from bokeh.models import NumeralTickFormatter
|
|
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
|
|
391
|
+
|
|
392
|
+
ax.xaxis.formatter = FuncTickFormatter(
|
|
393
|
+
code="return (tick / 1e6).toFixed(2);"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def save(
|
|
397
|
+
self,
|
|
398
|
+
fig: Any,
|
|
399
|
+
path: str,
|
|
400
|
+
dpi: int = 150,
|
|
401
|
+
bbox_inches: str = "tight",
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Save figure to file.
|
|
404
|
+
|
|
405
|
+
Supports .html for interactive and .png for static.
|
|
406
|
+
"""
|
|
407
|
+
if path.endswith(".html"):
|
|
408
|
+
output_file(path)
|
|
409
|
+
save(fig)
|
|
410
|
+
elif path.endswith(".png"):
|
|
411
|
+
export_png(fig, filename=path)
|
|
412
|
+
elif path.endswith(".svg"):
|
|
413
|
+
export_svgs(fig, filename=path)
|
|
414
|
+
else:
|
|
415
|
+
# Default to HTML
|
|
416
|
+
output_file(path)
|
|
417
|
+
save(fig)
|
|
418
|
+
|
|
419
|
+
def show(self, fig: Any) -> None:
|
|
420
|
+
"""Display the figure."""
|
|
421
|
+
show(fig)
|
|
422
|
+
|
|
423
|
+
def close(self, fig: Any) -> None:
|
|
424
|
+
"""Close the figure (no-op for bokeh)."""
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
def finalize_layout(
|
|
428
|
+
self,
|
|
429
|
+
fig: Any,
|
|
430
|
+
left: float = 0.08,
|
|
431
|
+
right: float = 0.95,
|
|
432
|
+
top: float = 0.95,
|
|
433
|
+
bottom: float = 0.1,
|
|
434
|
+
hspace: float = 0.08,
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Adjust layout (limited support in bokeh).
|
|
437
|
+
|
|
438
|
+
Bokeh layouts are mostly automatic.
|
|
439
|
+
"""
|
|
440
|
+
# Bokeh handles layout differently - column spacing is fixed
|
|
441
|
+
pass
|