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
|
@@ -71,8 +71,9 @@ class PlotlyBackend:
|
|
|
71
71
|
template="plotly_white",
|
|
72
72
|
)
|
|
73
73
|
|
|
74
|
-
# Return row
|
|
75
|
-
|
|
74
|
+
# Return (fig, row) tuples for each panel
|
|
75
|
+
# This matches the expected ax parameter format for all methods
|
|
76
|
+
panel_refs = [(fig, row) for row in range(1, n_panels + 1)]
|
|
76
77
|
return fig, panel_refs
|
|
77
78
|
|
|
78
79
|
def scatter(
|
|
@@ -100,9 +101,9 @@ class PlotlyBackend:
|
|
|
100
101
|
|
|
101
102
|
# Convert size (matplotlib uses area, plotly uses diameter)
|
|
102
103
|
if isinstance(sizes, (int, float)):
|
|
103
|
-
size = max(6, sizes
|
|
104
|
+
size = max(6, sizes**0.5) # Approximate conversion
|
|
104
105
|
else:
|
|
105
|
-
size = [max(6, s
|
|
106
|
+
size = [max(6, s**0.5) for s in sizes]
|
|
106
107
|
|
|
107
108
|
# Build hover template
|
|
108
109
|
if hover_data is not None:
|
|
@@ -110,10 +111,13 @@ class PlotlyBackend:
|
|
|
110
111
|
hover_cols = hover_data.columns.tolist()
|
|
111
112
|
hovertemplate = "<b>%{customdata[0]}</b><br>"
|
|
112
113
|
for i, col in enumerate(hover_cols[1:], 1):
|
|
113
|
-
|
|
114
|
+
col_lower = col.lower()
|
|
115
|
+
if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
|
|
114
116
|
hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
|
|
115
|
-
elif "r2" in
|
|
117
|
+
elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
|
|
116
118
|
hovertemplate += f"{col}: %{{customdata[{i}]:.3f}}<br>"
|
|
119
|
+
elif "pos" in col_lower:
|
|
120
|
+
hovertemplate += f"{col}: %{{customdata[{i}]:,.0f}}<br>"
|
|
117
121
|
else:
|
|
118
122
|
hovertemplate += f"{col}: %{{customdata[{i}]}}<br>"
|
|
119
123
|
hovertemplate += "<extra></extra>"
|
|
@@ -221,6 +225,7 @@ class PlotlyBackend:
|
|
|
221
225
|
color: str = "grey",
|
|
222
226
|
linestyle: str = "--",
|
|
223
227
|
linewidth: float = 1.0,
|
|
228
|
+
alpha: float = 1.0,
|
|
224
229
|
zorder: int = 1,
|
|
225
230
|
) -> Any:
|
|
226
231
|
"""Add a horizontal line across the panel."""
|
|
@@ -234,6 +239,7 @@ class PlotlyBackend:
|
|
|
234
239
|
line_dash=dash,
|
|
235
240
|
line_color=color,
|
|
236
241
|
line_width=linewidth,
|
|
242
|
+
opacity=alpha,
|
|
237
243
|
row=row,
|
|
238
244
|
col=1,
|
|
239
245
|
)
|
|
@@ -299,6 +305,33 @@ class PlotlyBackend:
|
|
|
299
305
|
col=1,
|
|
300
306
|
)
|
|
301
307
|
|
|
308
|
+
def add_polygon(
|
|
309
|
+
self,
|
|
310
|
+
ax: Tuple[go.Figure, int],
|
|
311
|
+
points: List[List[float]],
|
|
312
|
+
facecolor: str = "blue",
|
|
313
|
+
edgecolor: str = "black",
|
|
314
|
+
linewidth: float = 0.5,
|
|
315
|
+
zorder: int = 2,
|
|
316
|
+
) -> Any:
|
|
317
|
+
"""Add a polygon (e.g., triangle for strand arrows) to the panel."""
|
|
318
|
+
fig, row = ax
|
|
319
|
+
|
|
320
|
+
# Build SVG path from points
|
|
321
|
+
path = f"M {points[0][0]} {points[0][1]}"
|
|
322
|
+
for px, py in points[1:]:
|
|
323
|
+
path += f" L {px} {py}"
|
|
324
|
+
path += " Z"
|
|
325
|
+
|
|
326
|
+
fig.add_shape(
|
|
327
|
+
type="path",
|
|
328
|
+
path=path,
|
|
329
|
+
fillcolor=facecolor,
|
|
330
|
+
line=dict(color=edgecolor, width=linewidth),
|
|
331
|
+
row=row,
|
|
332
|
+
col=1,
|
|
333
|
+
)
|
|
334
|
+
|
|
302
335
|
def set_xlim(self, ax: Tuple[go.Figure, int], left: float, right: float) -> None:
|
|
303
336
|
"""Set x-axis limits."""
|
|
304
337
|
fig, row = ax
|
|
@@ -317,7 +350,11 @@ class PlotlyBackend:
|
|
|
317
350
|
"""Set x-axis label."""
|
|
318
351
|
fig, row = ax
|
|
319
352
|
xaxis = f"xaxis{row}" if row > 1 else "xaxis"
|
|
320
|
-
|
|
353
|
+
# Convert LaTeX-style labels to Unicode for Plotly
|
|
354
|
+
label = self._convert_label(label)
|
|
355
|
+
fig.update_layout(
|
|
356
|
+
**{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
|
|
357
|
+
)
|
|
321
358
|
|
|
322
359
|
def set_ylabel(
|
|
323
360
|
self, ax: Tuple[go.Figure, int], label: str, fontsize: int = 12
|
|
@@ -325,7 +362,28 @@ class PlotlyBackend:
|
|
|
325
362
|
"""Set y-axis label."""
|
|
326
363
|
fig, row = ax
|
|
327
364
|
yaxis = f"yaxis{row}" if row > 1 else "yaxis"
|
|
328
|
-
|
|
365
|
+
# Convert LaTeX-style labels to Unicode for Plotly
|
|
366
|
+
label = self._convert_label(label)
|
|
367
|
+
fig.update_layout(
|
|
368
|
+
**{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _convert_label(self, label: str) -> str:
|
|
372
|
+
"""Convert LaTeX-style labels to Unicode for Plotly display."""
|
|
373
|
+
# Common conversions for genomics plots
|
|
374
|
+
conversions = [
|
|
375
|
+
(r"$-\log_{10}$ P", "-log₁₀(P)"),
|
|
376
|
+
(r"$-\log_{10}$", "-log₁₀"),
|
|
377
|
+
(r"\log_{10}", "log₁₀"),
|
|
378
|
+
(r"$r^2$", "r²"),
|
|
379
|
+
(r"$R^2$", "R²"),
|
|
380
|
+
]
|
|
381
|
+
for latex, unicode_str in conversions:
|
|
382
|
+
if latex in label:
|
|
383
|
+
label = label.replace(latex, unicode_str)
|
|
384
|
+
# Remove any remaining $ markers
|
|
385
|
+
label = label.replace("$", "")
|
|
386
|
+
return label
|
|
329
387
|
|
|
330
388
|
def set_title(
|
|
331
389
|
self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
|
|
@@ -356,6 +414,146 @@ class PlotlyBackend:
|
|
|
356
414
|
|
|
357
415
|
return (fig, row, secondary_y)
|
|
358
416
|
|
|
417
|
+
def line_secondary(
|
|
418
|
+
self,
|
|
419
|
+
ax: Tuple[go.Figure, int],
|
|
420
|
+
x: pd.Series,
|
|
421
|
+
y: pd.Series,
|
|
422
|
+
color: str = "blue",
|
|
423
|
+
linewidth: float = 1.5,
|
|
424
|
+
alpha: float = 1.0,
|
|
425
|
+
linestyle: str = "-",
|
|
426
|
+
label: Optional[str] = None,
|
|
427
|
+
yaxis_name: str = "y2",
|
|
428
|
+
) -> Any:
|
|
429
|
+
"""Create a line plot on secondary y-axis."""
|
|
430
|
+
fig, row = ax
|
|
431
|
+
|
|
432
|
+
dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
|
|
433
|
+
dash = dash_map.get(linestyle, "solid")
|
|
434
|
+
|
|
435
|
+
trace = go.Scatter(
|
|
436
|
+
x=x,
|
|
437
|
+
y=y,
|
|
438
|
+
mode="lines",
|
|
439
|
+
line=dict(color=color, width=linewidth, dash=dash),
|
|
440
|
+
opacity=alpha,
|
|
441
|
+
name=label or "",
|
|
442
|
+
showlegend=label is not None,
|
|
443
|
+
yaxis=yaxis_name,
|
|
444
|
+
hoverinfo="skip",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
fig.add_trace(trace, row=row, col=1)
|
|
448
|
+
return trace
|
|
449
|
+
|
|
450
|
+
def fill_between_secondary(
|
|
451
|
+
self,
|
|
452
|
+
ax: Tuple[go.Figure, int],
|
|
453
|
+
x: pd.Series,
|
|
454
|
+
y1: Union[float, pd.Series],
|
|
455
|
+
y2: Union[float, pd.Series],
|
|
456
|
+
color: str = "blue",
|
|
457
|
+
alpha: float = 0.3,
|
|
458
|
+
yaxis_name: str = "y2",
|
|
459
|
+
) -> Any:
|
|
460
|
+
"""Fill area between two y-values on secondary y-axis."""
|
|
461
|
+
fig, row = ax
|
|
462
|
+
|
|
463
|
+
if isinstance(y1, (int, float)):
|
|
464
|
+
y1 = pd.Series([y1] * len(x))
|
|
465
|
+
|
|
466
|
+
trace = go.Scatter(
|
|
467
|
+
x=pd.concat([x, x[::-1]]),
|
|
468
|
+
y=pd.concat([y2, y1[::-1]]),
|
|
469
|
+
fill="toself",
|
|
470
|
+
fillcolor=color,
|
|
471
|
+
opacity=alpha,
|
|
472
|
+
line=dict(width=0),
|
|
473
|
+
showlegend=False,
|
|
474
|
+
hoverinfo="skip",
|
|
475
|
+
yaxis=yaxis_name,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
fig.add_trace(trace, row=row, col=1)
|
|
479
|
+
return trace
|
|
480
|
+
|
|
481
|
+
def set_secondary_ylim(
|
|
482
|
+
self,
|
|
483
|
+
ax: Tuple[go.Figure, int],
|
|
484
|
+
bottom: float,
|
|
485
|
+
top: float,
|
|
486
|
+
yaxis_name: str = "y2",
|
|
487
|
+
) -> None:
|
|
488
|
+
"""Set secondary y-axis limits."""
|
|
489
|
+
fig, row = ax
|
|
490
|
+
yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
|
|
491
|
+
fig.update_layout(**{yaxis_key: dict(range=[bottom, top])})
|
|
492
|
+
|
|
493
|
+
def set_secondary_ylabel(
|
|
494
|
+
self,
|
|
495
|
+
ax: Tuple[go.Figure, int],
|
|
496
|
+
label: str,
|
|
497
|
+
color: str = "black",
|
|
498
|
+
fontsize: int = 10,
|
|
499
|
+
yaxis_name: str = "y2",
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Set secondary y-axis label."""
|
|
502
|
+
fig, row = ax
|
|
503
|
+
label = self._convert_label(label)
|
|
504
|
+
yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
|
|
505
|
+
fig.update_layout(
|
|
506
|
+
**{
|
|
507
|
+
yaxis_key: dict(
|
|
508
|
+
title=dict(text=label, font=dict(size=fontsize, color=color)),
|
|
509
|
+
tickfont=dict(color=color),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def add_ld_legend(
|
|
515
|
+
self,
|
|
516
|
+
ax: Tuple[go.Figure, int],
|
|
517
|
+
ld_bins: List[Tuple[float, str, str]],
|
|
518
|
+
lead_snp_color: str,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Add LD color legend using invisible scatter traces."""
|
|
521
|
+
fig, row = ax
|
|
522
|
+
|
|
523
|
+
# Add LD bin markers (no lead SNP - it's shown in the actual plot)
|
|
524
|
+
for _, label, color in ld_bins:
|
|
525
|
+
fig.add_trace(
|
|
526
|
+
go.Scatter(
|
|
527
|
+
x=[None],
|
|
528
|
+
y=[None],
|
|
529
|
+
mode="markers",
|
|
530
|
+
marker=dict(
|
|
531
|
+
symbol="square",
|
|
532
|
+
size=10,
|
|
533
|
+
color=color,
|
|
534
|
+
line=dict(color="black", width=0.5),
|
|
535
|
+
),
|
|
536
|
+
name=label,
|
|
537
|
+
showlegend=True,
|
|
538
|
+
),
|
|
539
|
+
row=row,
|
|
540
|
+
col=1,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Position legend
|
|
544
|
+
fig.update_layout(
|
|
545
|
+
legend=dict(
|
|
546
|
+
x=0.99,
|
|
547
|
+
y=0.99,
|
|
548
|
+
xanchor="right",
|
|
549
|
+
yanchor="top",
|
|
550
|
+
title=dict(text="r²"),
|
|
551
|
+
bgcolor="rgba(255,255,255,0.9)",
|
|
552
|
+
bordercolor="black",
|
|
553
|
+
borderwidth=1,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
359
557
|
def add_legend(
|
|
360
558
|
self,
|
|
361
559
|
ax: Tuple[go.Figure, int],
|
|
@@ -395,32 +593,20 @@ class PlotlyBackend:
|
|
|
395
593
|
|
|
396
594
|
Plotly doesn't have spines, but we can hide axis lines.
|
|
397
595
|
"""
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
yaxis = f"yaxis{row}" if row > 1 else "yaxis"
|
|
402
|
-
|
|
403
|
-
if "top" in spines or "right" in spines:
|
|
404
|
-
# Plotly's template "plotly_white" already hides these
|
|
405
|
-
pass
|
|
596
|
+
# Plotly's template "plotly_white" already hides top/right lines
|
|
597
|
+
# No action needed - method exists for API compatibility
|
|
598
|
+
pass
|
|
406
599
|
|
|
407
600
|
def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
|
|
408
|
-
"""Format x-axis to show megabase values.
|
|
409
|
-
fig, row = ax
|
|
410
|
-
xaxis = f"xaxis{row}" if row > 1 else "xaxis"
|
|
411
|
-
|
|
412
|
-
fig.update_layout(
|
|
413
|
-
**{
|
|
414
|
-
xaxis: dict(
|
|
415
|
-
tickformat=".2f",
|
|
416
|
-
ticksuffix=" Mb",
|
|
417
|
-
tickvals=None, # Auto
|
|
418
|
-
)
|
|
419
|
-
}
|
|
420
|
-
)
|
|
601
|
+
"""Format x-axis to show megabase values.
|
|
421
602
|
|
|
422
|
-
|
|
423
|
-
|
|
603
|
+
Stores the row for later tick formatting in finalize_layout.
|
|
604
|
+
"""
|
|
605
|
+
fig, row = ax
|
|
606
|
+
# Store that this axis needs Mb formatting
|
|
607
|
+
if not hasattr(fig, "_mb_format_rows"):
|
|
608
|
+
fig._mb_format_rows = []
|
|
609
|
+
fig._mb_format_rows.append(row)
|
|
424
610
|
|
|
425
611
|
def save(
|
|
426
612
|
self,
|
|
@@ -457,7 +643,7 @@ class PlotlyBackend:
|
|
|
457
643
|
bottom: float = 0.1,
|
|
458
644
|
hspace: float = 0.08,
|
|
459
645
|
) -> None:
|
|
460
|
-
"""Adjust layout margins.
|
|
646
|
+
"""Adjust layout margins and apply Mb tick formatting.
|
|
461
647
|
|
|
462
648
|
Args:
|
|
463
649
|
fig: Figure object.
|
|
@@ -472,3 +658,58 @@ class PlotlyBackend:
|
|
|
472
658
|
b=int(bottom * fig.layout.height) if fig.layout.height else 80,
|
|
473
659
|
)
|
|
474
660
|
)
|
|
661
|
+
|
|
662
|
+
# Apply Mb tick formatting to marked axes
|
|
663
|
+
if hasattr(fig, "_mb_format_rows"):
|
|
664
|
+
import numpy as np
|
|
665
|
+
|
|
666
|
+
for row in fig._mb_format_rows:
|
|
667
|
+
xaxis_name = f"xaxis{row}" if row > 1 else "xaxis"
|
|
668
|
+
xaxis = getattr(fig.layout, xaxis_name, None)
|
|
669
|
+
|
|
670
|
+
# Get x-range from the axis or compute from data
|
|
671
|
+
x_range = None
|
|
672
|
+
if xaxis and xaxis.range:
|
|
673
|
+
x_range = xaxis.range
|
|
674
|
+
else:
|
|
675
|
+
# Compute from trace data
|
|
676
|
+
x_vals = []
|
|
677
|
+
for trace in fig.data:
|
|
678
|
+
if hasattr(trace, "x") and trace.x is not None:
|
|
679
|
+
x_vals.extend(list(trace.x))
|
|
680
|
+
if x_vals:
|
|
681
|
+
x_range = [min(x_vals), max(x_vals)]
|
|
682
|
+
|
|
683
|
+
if x_range:
|
|
684
|
+
# Create nice tick values in Mb
|
|
685
|
+
x_min_mb = x_range[0] / 1e6
|
|
686
|
+
x_max_mb = x_range[1] / 1e6
|
|
687
|
+
span_mb = x_max_mb - x_min_mb
|
|
688
|
+
|
|
689
|
+
# Choose tick spacing based on range
|
|
690
|
+
if span_mb <= 0.5:
|
|
691
|
+
tick_step = 0.1
|
|
692
|
+
elif span_mb <= 2:
|
|
693
|
+
tick_step = 0.25
|
|
694
|
+
elif span_mb <= 5:
|
|
695
|
+
tick_step = 0.5
|
|
696
|
+
elif span_mb <= 20:
|
|
697
|
+
tick_step = 2
|
|
698
|
+
else:
|
|
699
|
+
tick_step = 5
|
|
700
|
+
|
|
701
|
+
# Generate ticks
|
|
702
|
+
first_tick = np.ceil(x_min_mb / tick_step) * tick_step
|
|
703
|
+
tickvals_mb = np.arange(first_tick, x_max_mb + tick_step / 2, tick_step)
|
|
704
|
+
tickvals_bp = [v * 1e6 for v in tickvals_mb]
|
|
705
|
+
ticktext = [f"{v:.2f}" for v in tickvals_mb]
|
|
706
|
+
|
|
707
|
+
fig.update_layout(
|
|
708
|
+
**{
|
|
709
|
+
xaxis_name: dict(
|
|
710
|
+
tickvals=tickvals_bp,
|
|
711
|
+
ticktext=ticktext,
|
|
712
|
+
ticksuffix=" Mb",
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
)
|
pylocuszoom/colors.py
CHANGED
|
@@ -29,6 +29,101 @@ LD_NA_LABEL = "NA"
|
|
|
29
29
|
# Lead SNP color (purple diamond)
|
|
30
30
|
LEAD_SNP_COLOR = "#7D26CD" # purple3
|
|
31
31
|
|
|
32
|
+
# Fine-mapping/SuSiE credible set colors
|
|
33
|
+
# Colors for up to 10 credible sets, matching locuszoomr style
|
|
34
|
+
CREDIBLE_SET_COLORS: List[str] = [
|
|
35
|
+
"#FF7F00", # orange (CS1)
|
|
36
|
+
"#1F78B4", # blue (CS2)
|
|
37
|
+
"#33A02C", # green (CS3)
|
|
38
|
+
"#E31A1C", # red (CS4)
|
|
39
|
+
"#6A3D9A", # purple (CS5)
|
|
40
|
+
"#B15928", # brown (CS6)
|
|
41
|
+
"#FB9A99", # pink (CS7)
|
|
42
|
+
"#A6CEE3", # light blue (CS8)
|
|
43
|
+
"#B2DF8A", # light green (CS9)
|
|
44
|
+
"#FDBF6F", # light orange (CS10)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# PIP line color (when not showing credible sets)
|
|
48
|
+
PIP_LINE_COLOR = "#FF7F00" # orange
|
|
49
|
+
|
|
50
|
+
# eQTL effect size bins - matches locuszoomr color scheme
|
|
51
|
+
# Format: (min_threshold, max_threshold, label, color)
|
|
52
|
+
# Positive effects (upward triangles)
|
|
53
|
+
EQTL_POSITIVE_BINS: List[Tuple[float, float, str, str]] = [
|
|
54
|
+
(0.3, 0.4, "0.3 : 0.4", "#8B1A1A"), # dark red/maroon
|
|
55
|
+
(0.2, 0.3, "0.2 : 0.3", "#FF6600"), # orange
|
|
56
|
+
(0.1, 0.2, "0.1 : 0.2", "#FFB347"), # light orange
|
|
57
|
+
]
|
|
58
|
+
# Negative effects (downward triangles)
|
|
59
|
+
EQTL_NEGATIVE_BINS: List[Tuple[float, float, str, str]] = [
|
|
60
|
+
(-0.2, -0.1, "-0.2 : -0.1", "#66CDAA"), # medium aquamarine
|
|
61
|
+
(-0.3, -0.2, "-0.3 : -0.2", "#4682B4"), # steel blue
|
|
62
|
+
(-0.4, -0.3, "-0.4 : -0.3", "#00008B"), # dark blue
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_eqtl_color(effect: Optional[float]) -> str:
|
|
67
|
+
"""Get color based on eQTL effect size.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
effect: Effect size (beta coefficient).
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Hex color code string.
|
|
74
|
+
"""
|
|
75
|
+
if _is_missing(effect):
|
|
76
|
+
return LD_NA_COLOR
|
|
77
|
+
|
|
78
|
+
if effect >= 0:
|
|
79
|
+
for min_t, max_t, _, color in EQTL_POSITIVE_BINS:
|
|
80
|
+
if min_t <= effect < max_t or (max_t == 0.4 and effect >= max_t):
|
|
81
|
+
return color
|
|
82
|
+
return EQTL_POSITIVE_BINS[-1][3] # smallest positive bin
|
|
83
|
+
else:
|
|
84
|
+
for min_t, max_t, _, color in EQTL_NEGATIVE_BINS:
|
|
85
|
+
if min_t < effect <= max_t or (min_t == -0.4 and effect <= min_t):
|
|
86
|
+
return color
|
|
87
|
+
return EQTL_NEGATIVE_BINS[-1][3] # smallest negative bin
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_eqtl_bin(effect: Optional[float]) -> str:
|
|
91
|
+
"""Get eQTL effect bin label.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
effect: Effect size (beta coefficient).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Bin label string.
|
|
98
|
+
"""
|
|
99
|
+
if _is_missing(effect):
|
|
100
|
+
return LD_NA_LABEL
|
|
101
|
+
|
|
102
|
+
if effect >= 0:
|
|
103
|
+
for min_t, max_t, label, _ in EQTL_POSITIVE_BINS:
|
|
104
|
+
if min_t <= effect < max_t or (max_t == 0.4 and effect >= max_t):
|
|
105
|
+
return label
|
|
106
|
+
return EQTL_POSITIVE_BINS[-1][2]
|
|
107
|
+
else:
|
|
108
|
+
for min_t, max_t, label, _ in EQTL_NEGATIVE_BINS:
|
|
109
|
+
if min_t < effect <= max_t or (min_t == -0.4 and effect <= min_t):
|
|
110
|
+
return label
|
|
111
|
+
return EQTL_NEGATIVE_BINS[-1][2]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_eqtl_color_palette() -> dict[str, str]:
|
|
115
|
+
"""Get color palette for eQTL effect bins.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dictionary mapping bin labels to hex colors.
|
|
119
|
+
"""
|
|
120
|
+
palette = {}
|
|
121
|
+
for _, _, label, color in EQTL_POSITIVE_BINS:
|
|
122
|
+
palette[label] = color
|
|
123
|
+
for _, _, label, color in EQTL_NEGATIVE_BINS:
|
|
124
|
+
palette[label] = color
|
|
125
|
+
return palette
|
|
126
|
+
|
|
32
127
|
|
|
33
128
|
def get_ld_color(r2: Optional[float]) -> str:
|
|
34
129
|
"""Get LocusZoom-style color based on LD R² value.
|
|
@@ -105,3 +200,40 @@ def get_ld_color_palette() -> dict[str, str]:
|
|
|
105
200
|
palette = {label: color for _, label, color in LD_BINS}
|
|
106
201
|
palette[LD_NA_LABEL] = LD_NA_COLOR
|
|
107
202
|
return palette
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_credible_set_color(cs_id: int) -> str:
|
|
206
|
+
"""Get color for a credible set.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
cs_id: Credible set ID (1-indexed).
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Hex color code string.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> get_credible_set_color(1)
|
|
216
|
+
'#FF7F00'
|
|
217
|
+
"""
|
|
218
|
+
if cs_id < 1:
|
|
219
|
+
return LD_NA_COLOR
|
|
220
|
+
# Use modulo to cycle through colors if more than 10 credible sets
|
|
221
|
+
idx = (cs_id - 1) % len(CREDIBLE_SET_COLORS)
|
|
222
|
+
return CREDIBLE_SET_COLORS[idx]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_credible_set_color_palette(n_sets: int = 10) -> dict[int, str]:
|
|
226
|
+
"""Get color palette for credible sets.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
n_sets: Number of credible sets to include.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Dictionary mapping credible set IDs (1-indexed) to hex colors.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> palette = get_credible_set_color_palette(3)
|
|
236
|
+
>>> palette[1]
|
|
237
|
+
'#FF7F00'
|
|
238
|
+
"""
|
|
239
|
+
return {i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)}
|
pylocuszoom/eqtl.py
CHANGED
|
@@ -11,7 +11,6 @@ import pandas as pd
|
|
|
11
11
|
|
|
12
12
|
from .logging import logger
|
|
13
13
|
|
|
14
|
-
|
|
15
14
|
REQUIRED_EQTL_COLS = ["pos", "p_value"]
|
|
16
15
|
OPTIONAL_EQTL_COLS = ["gene", "effect_size", "rs", "se"]
|
|
17
16
|
|
|
@@ -109,7 +108,9 @@ def filter_eqtl_by_region(
|
|
|
109
108
|
mask = mask & (df_chrom == chrom_str)
|
|
110
109
|
|
|
111
110
|
filtered = df[mask].copy()
|
|
112
|
-
logger.debug(
|
|
111
|
+
logger.debug(
|
|
112
|
+
f"Filtered eQTL data to {len(filtered)} variants in region chr{chrom}:{start}-{end}"
|
|
113
|
+
)
|
|
113
114
|
return filtered
|
|
114
115
|
|
|
115
116
|
|