pylocuszoom 0.2.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/backends/base.py +2 -0
- pylocuszoom/backends/bokeh_backend.py +220 -48
- pylocuszoom/backends/matplotlib_backend.py +29 -7
- pylocuszoom/backends/plotly_backend.py +262 -20
- pylocuszoom/finemapping.py +0 -1
- pylocuszoom/gene_track.py +231 -23
- pylocuszoom/plotter.py +277 -139
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.3.0.dist-info}/METADATA +28 -14
- pylocuszoom-0.3.0.dist-info/RECORD +21 -0
- pylocuszoom-0.2.0.dist-info/RECORD +0 -21
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.3.0.dist-info}/WHEEL +0 -0
- {pylocuszoom-0.2.0.dist-info → pylocuszoom-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
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:
|
|
@@ -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,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
|
|
|
@@ -118,29 +114,34 @@ class BokehBackend:
|
|
|
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
|
|
@@ -303,6 +307,28 @@ class BokehBackend:
|
|
|
303
307
|
line_width=linewidth,
|
|
304
308
|
)
|
|
305
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
|
+
|
|
306
332
|
def set_xlim(self, ax: figure, left: float, right: float) -> None:
|
|
307
333
|
"""Set x-axis limits."""
|
|
308
334
|
ax.x_range.start = left
|
|
@@ -315,14 +341,31 @@ class BokehBackend:
|
|
|
315
341
|
|
|
316
342
|
def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
317
343
|
"""Set x-axis label."""
|
|
344
|
+
label = self._convert_label(label)
|
|
318
345
|
ax.xaxis.axis_label = label
|
|
319
346
|
ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
320
347
|
|
|
321
348
|
def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
|
|
322
349
|
"""Set y-axis label."""
|
|
350
|
+
label = self._convert_label(label)
|
|
323
351
|
ax.yaxis.axis_label = label
|
|
324
352
|
ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
|
|
325
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
|
+
|
|
326
369
|
def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
|
|
327
370
|
"""Set figure title."""
|
|
328
371
|
ax.title.text = title
|
|
@@ -341,6 +384,141 @@ class BokehBackend:
|
|
|
341
384
|
|
|
342
385
|
return "secondary"
|
|
343
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
|
+
|
|
344
522
|
def add_legend(
|
|
345
523
|
self,
|
|
346
524
|
ax: figure,
|
|
@@ -369,26 +547,20 @@ class BokehBackend:
|
|
|
369
547
|
return ax.legend
|
|
370
548
|
|
|
371
549
|
def hide_spines(self, ax: figure, spines: List[str]) -> None:
|
|
372
|
-
"""Hide specified axis spines.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
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
|
|
379
556
|
|
|
380
557
|
def format_xaxis_mb(self, ax: figure) -> None:
|
|
381
558
|
"""Format x-axis to show megabase values."""
|
|
382
|
-
from bokeh.models import
|
|
559
|
+
from bokeh.models import CustomJSTickFormatter
|
|
383
560
|
|
|
384
|
-
ax.xaxis.formatter =
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
# We need to scale values or use a custom formatter
|
|
388
|
-
# For now, assume values are already in bp and need /1e6
|
|
389
|
-
from bokeh.models import FuncTickFormatter
|
|
390
|
-
|
|
391
|
-
ax.xaxis.formatter = FuncTickFormatter(code="return (tick / 1e6).toFixed(2);")
|
|
561
|
+
ax.xaxis.formatter = CustomJSTickFormatter(
|
|
562
|
+
code="return (tick / 1e6).toFixed(2);"
|
|
563
|
+
)
|
|
392
564
|
|
|
393
565
|
def save(
|
|
394
566
|
self,
|
|
@@ -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)
|