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
|
@@ -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(
|
|
@@ -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,6 +350,8 @@ class PlotlyBackend:
|
|
|
317
350
|
"""Set x-axis label."""
|
|
318
351
|
fig, row = ax
|
|
319
352
|
xaxis = f"xaxis{row}" if row > 1 else "xaxis"
|
|
353
|
+
# Convert LaTeX-style labels to Unicode for Plotly
|
|
354
|
+
label = self._convert_label(label)
|
|
320
355
|
fig.update_layout(
|
|
321
356
|
**{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
|
|
322
357
|
)
|
|
@@ -327,10 +362,29 @@ class PlotlyBackend:
|
|
|
327
362
|
"""Set y-axis label."""
|
|
328
363
|
fig, row = ax
|
|
329
364
|
yaxis = f"yaxis{row}" if row > 1 else "yaxis"
|
|
365
|
+
# Convert LaTeX-style labels to Unicode for Plotly
|
|
366
|
+
label = self._convert_label(label)
|
|
330
367
|
fig.update_layout(
|
|
331
368
|
**{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
|
|
332
369
|
)
|
|
333
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
|
|
387
|
+
|
|
334
388
|
def set_title(
|
|
335
389
|
self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
|
|
336
390
|
) -> None:
|
|
@@ -360,6 +414,146 @@ class PlotlyBackend:
|
|
|
360
414
|
|
|
361
415
|
return (fig, row, secondary_y)
|
|
362
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
|
+
|
|
363
557
|
def add_legend(
|
|
364
558
|
self,
|
|
365
559
|
ax: Tuple[go.Figure, int],
|
|
@@ -404,22 +598,15 @@ class PlotlyBackend:
|
|
|
404
598
|
pass
|
|
405
599
|
|
|
406
600
|
def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
|
|
407
|
-
"""Format x-axis to show megabase values.
|
|
408
|
-
fig, row = ax
|
|
409
|
-
xaxis = f"xaxis{row}" if row > 1 else "xaxis"
|
|
601
|
+
"""Format x-axis to show megabase values.
|
|
410
602
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
# Apply custom tick formatting via ticktext/tickvals if needed
|
|
422
|
-
# For now, let plotly auto-format
|
|
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)
|
|
423
610
|
|
|
424
611
|
def save(
|
|
425
612
|
self,
|
|
@@ -456,7 +643,7 @@ class PlotlyBackend:
|
|
|
456
643
|
bottom: float = 0.1,
|
|
457
644
|
hspace: float = 0.08,
|
|
458
645
|
) -> None:
|
|
459
|
-
"""Adjust layout margins.
|
|
646
|
+
"""Adjust layout margins and apply Mb tick formatting.
|
|
460
647
|
|
|
461
648
|
Args:
|
|
462
649
|
fig: Figure object.
|
|
@@ -471,3 +658,58 @@ class PlotlyBackend:
|
|
|
471
658
|
b=int(bottom * fig.layout.height) if fig.layout.height else 80,
|
|
472
659
|
)
|
|
473
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/finemapping.py
CHANGED
pylocuszoom/gene_track.py
CHANGED
|
@@ -7,7 +7,7 @@ Provides LocusZoom-style gene track plotting with:
|
|
|
7
7
|
- Gene name labels
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from typing import List, Optional, Union
|
|
10
|
+
from typing import Any, List, Optional, Union
|
|
11
11
|
|
|
12
12
|
import pandas as pd
|
|
13
13
|
from matplotlib.axes import Axes
|
|
@@ -17,15 +17,15 @@ from .utils import normalize_chrom
|
|
|
17
17
|
|
|
18
18
|
# Strand-specific colors (distinct from LD palette)
|
|
19
19
|
STRAND_COLORS: dict[Optional[str], str] = {
|
|
20
|
-
"+": "#
|
|
21
|
-
"-": "#
|
|
20
|
+
"+": "#DAA520", # Goldenrod for forward strand
|
|
21
|
+
"-": "#6BB3FF", # Light blue for reverse strand
|
|
22
22
|
None: "#999999", # Light grey if no strand info
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
# Layout constants
|
|
26
|
-
ROW_HEIGHT = 0.
|
|
27
|
-
GENE_AREA = 0.
|
|
28
|
-
EXON_HEIGHT = 0.
|
|
26
|
+
ROW_HEIGHT = 0.35 # Total height per row (reduced for tighter spacing)
|
|
27
|
+
GENE_AREA = 0.25 # Bottom portion for gene drawing
|
|
28
|
+
EXON_HEIGHT = 0.20 # Exon rectangle height
|
|
29
29
|
INTRON_HEIGHT = 0.02 # Thin intron line
|
|
30
30
|
|
|
31
31
|
|
|
@@ -175,7 +175,7 @@ def plot_gene_track(
|
|
|
175
175
|
# Set y-axis limits - small bottom margin for gene body, tight top
|
|
176
176
|
max_row = max(positions) if positions else 0
|
|
177
177
|
bottom_margin = EXON_HEIGHT / 2 + 0.02 # Room for bottom gene
|
|
178
|
-
top_margin = 0.
|
|
178
|
+
top_margin = 0.05 # Minimal space above top label
|
|
179
179
|
ax.set_ylim(
|
|
180
180
|
-bottom_margin,
|
|
181
181
|
(max_row + 1) * ROW_HEIGHT - ROW_HEIGHT + GENE_AREA + top_margin,
|
|
@@ -193,6 +193,8 @@ def plot_gene_track(
|
|
|
193
193
|
& (exons_df["start"] <= end)
|
|
194
194
|
].copy()
|
|
195
195
|
|
|
196
|
+
region_width = end - start
|
|
197
|
+
|
|
196
198
|
for idx, (_, gene) in enumerate(region_genes.iterrows()):
|
|
197
199
|
gene_start = max(int(gene["start"]), start)
|
|
198
200
|
gene_end = min(int(gene["end"]), end)
|
|
@@ -258,38 +260,41 @@ def plot_gene_track(
|
|
|
258
260
|
# Add strand direction triangles (tip, center, tail)
|
|
259
261
|
if "strand" in gene.index:
|
|
260
262
|
strand = gene["strand"]
|
|
261
|
-
region_width = end - start
|
|
262
|
-
gene_width = gene_end - gene_start
|
|
263
263
|
arrow_dir = 1 if strand == "+" else -1
|
|
264
264
|
|
|
265
265
|
# Triangle dimensions
|
|
266
266
|
tri_height = EXON_HEIGHT * 0.35
|
|
267
267
|
tri_width = region_width * 0.006
|
|
268
268
|
|
|
269
|
-
# Arrow positions: front, middle, back
|
|
269
|
+
# Arrow positions: front, middle, back (tip positions)
|
|
270
|
+
tip_offset = tri_width / 2 # Tiny offset to keep tip inside gene
|
|
271
|
+
tail_offset = tri_width * 1.5 # Offset for tail arrow from gene start/end
|
|
272
|
+
gene_center = (gene_start + gene_end) / 2
|
|
270
273
|
if arrow_dir == 1: # Forward strand
|
|
271
|
-
|
|
272
|
-
gene_start, #
|
|
273
|
-
|
|
274
|
-
gene_end, #
|
|
274
|
+
arrow_tip_positions = [
|
|
275
|
+
gene_start + tail_offset, # Tail (tip inside gene)
|
|
276
|
+
gene_center + tri_width / 2, # Middle (arrow center at gene center)
|
|
277
|
+
gene_end - tip_offset, # Tip (near gene end)
|
|
275
278
|
]
|
|
279
|
+
arrow_color = "#000000" # Black for forward
|
|
276
280
|
else: # Reverse strand
|
|
277
|
-
|
|
278
|
-
gene_end, #
|
|
279
|
-
|
|
280
|
-
gene_start, #
|
|
281
|
+
arrow_tip_positions = [
|
|
282
|
+
gene_end - tail_offset, # Tail (tip inside gene)
|
|
283
|
+
gene_center - tri_width / 2, # Middle (arrow center at gene center)
|
|
284
|
+
gene_start + tip_offset, # Tip (near gene start)
|
|
281
285
|
]
|
|
286
|
+
arrow_color = "#333333" # Dark grey for reverse
|
|
282
287
|
|
|
283
|
-
for
|
|
288
|
+
for tip_x in arrow_tip_positions:
|
|
284
289
|
if arrow_dir == 1:
|
|
285
|
-
|
|
290
|
+
base_x = tip_x - tri_width
|
|
286
291
|
tri_points = [
|
|
287
292
|
[tip_x, y_gene], # Tip pointing right
|
|
288
293
|
[base_x, y_gene + tri_height],
|
|
289
294
|
[base_x, y_gene - tri_height],
|
|
290
295
|
]
|
|
291
296
|
else:
|
|
292
|
-
|
|
297
|
+
base_x = tip_x + tri_width
|
|
293
298
|
tri_points = [
|
|
294
299
|
[tip_x, y_gene], # Tip pointing left
|
|
295
300
|
[base_x, y_gene + tri_height],
|
|
@@ -299,8 +304,8 @@ def plot_gene_track(
|
|
|
299
304
|
triangle = Polygon(
|
|
300
305
|
tri_points,
|
|
301
306
|
closed=True,
|
|
302
|
-
facecolor=
|
|
303
|
-
edgecolor=
|
|
307
|
+
facecolor=arrow_color,
|
|
308
|
+
edgecolor=arrow_color,
|
|
304
309
|
linewidth=0.5,
|
|
305
310
|
zorder=5,
|
|
306
311
|
)
|
|
@@ -322,3 +327,206 @@ def plot_gene_track(
|
|
|
322
327
|
zorder=4,
|
|
323
328
|
clip_on=True,
|
|
324
329
|
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def plot_gene_track_generic(
|
|
333
|
+
ax: Any,
|
|
334
|
+
backend: Any,
|
|
335
|
+
genes_df: pd.DataFrame,
|
|
336
|
+
chrom: Union[int, str],
|
|
337
|
+
start: int,
|
|
338
|
+
end: int,
|
|
339
|
+
exons_df: Optional[pd.DataFrame] = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Plot gene annotations using a backend-agnostic approach.
|
|
342
|
+
|
|
343
|
+
This function works with matplotlib, plotly, and bokeh backends.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
ax: Axes object (format depends on backend).
|
|
347
|
+
backend: Backend instance with drawing methods.
|
|
348
|
+
genes_df: Gene annotations with chr, start, end, gene_name,
|
|
349
|
+
and optionally strand (+/-) column.
|
|
350
|
+
chrom: Chromosome number or string.
|
|
351
|
+
start: Region start position.
|
|
352
|
+
end: Region end position.
|
|
353
|
+
exons_df: Exon annotations with chr, start, end, gene_name
|
|
354
|
+
columns for drawing exon structure. Optional.
|
|
355
|
+
"""
|
|
356
|
+
chrom_str = normalize_chrom(chrom)
|
|
357
|
+
region_genes = genes_df[
|
|
358
|
+
(genes_df["chr"].astype(str).str.replace("chr", "", regex=False) == chrom_str)
|
|
359
|
+
& (genes_df["end"] >= start)
|
|
360
|
+
& (genes_df["start"] <= end)
|
|
361
|
+
].copy()
|
|
362
|
+
|
|
363
|
+
backend.set_xlim(ax, start, end)
|
|
364
|
+
backend.set_ylabel(ax, "", fontsize=10)
|
|
365
|
+
|
|
366
|
+
if region_genes.empty:
|
|
367
|
+
backend.set_ylim(ax, 0, 1)
|
|
368
|
+
backend.add_text(
|
|
369
|
+
ax,
|
|
370
|
+
(start + end) / 2,
|
|
371
|
+
0.5,
|
|
372
|
+
"No genes",
|
|
373
|
+
fontsize=9,
|
|
374
|
+
ha="center",
|
|
375
|
+
va="center",
|
|
376
|
+
color="grey",
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Assign vertical positions to avoid overlap
|
|
381
|
+
region_genes = region_genes.sort_values("start")
|
|
382
|
+
positions = assign_gene_positions(region_genes, start, end)
|
|
383
|
+
|
|
384
|
+
# Set y-axis limits - small bottom margin for gene body, tight top
|
|
385
|
+
max_row = max(positions) if positions else 0
|
|
386
|
+
bottom_margin = EXON_HEIGHT / 2 + 0.02 # Room for bottom gene
|
|
387
|
+
top_margin = 0.05 # Minimal space above top label
|
|
388
|
+
backend.set_ylim(
|
|
389
|
+
ax,
|
|
390
|
+
-bottom_margin,
|
|
391
|
+
(max_row + 1) * ROW_HEIGHT - ROW_HEIGHT + GENE_AREA + top_margin,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Filter exons for this region if available
|
|
395
|
+
region_exons = None
|
|
396
|
+
if exons_df is not None and not exons_df.empty:
|
|
397
|
+
region_exons = exons_df[
|
|
398
|
+
(
|
|
399
|
+
exons_df["chr"].astype(str).str.replace("chr", "", regex=False)
|
|
400
|
+
== chrom_str
|
|
401
|
+
)
|
|
402
|
+
& (exons_df["end"] >= start)
|
|
403
|
+
& (exons_df["start"] <= end)
|
|
404
|
+
].copy()
|
|
405
|
+
|
|
406
|
+
region_width = end - start
|
|
407
|
+
|
|
408
|
+
for idx, (_, gene) in enumerate(region_genes.iterrows()):
|
|
409
|
+
gene_start = max(int(gene["start"]), start)
|
|
410
|
+
gene_end = min(int(gene["end"]), end)
|
|
411
|
+
row = positions[idx]
|
|
412
|
+
gene_name = gene.get("gene_name", "")
|
|
413
|
+
|
|
414
|
+
# Get strand-specific color
|
|
415
|
+
strand = gene.get("strand") if "strand" in gene.index else None
|
|
416
|
+
gene_col = STRAND_COLORS.get(strand, STRAND_COLORS[None])
|
|
417
|
+
|
|
418
|
+
# Y position: bottom of row + offset for gene area
|
|
419
|
+
y_gene = row * ROW_HEIGHT + 0.05
|
|
420
|
+
y_label = y_gene + EXON_HEIGHT / 2 + 0.01 # Just above gene top
|
|
421
|
+
|
|
422
|
+
# Check if we have exon data for this gene
|
|
423
|
+
gene_exons = None
|
|
424
|
+
if region_exons is not None and not region_exons.empty and gene_name:
|
|
425
|
+
gene_exons = region_exons[region_exons["gene_name"] == gene_name].copy()
|
|
426
|
+
|
|
427
|
+
if gene_exons is not None and not gene_exons.empty:
|
|
428
|
+
# Draw intron line (thin horizontal line spanning gene)
|
|
429
|
+
backend.add_rectangle(
|
|
430
|
+
ax,
|
|
431
|
+
(gene_start, y_gene - INTRON_HEIGHT / 2),
|
|
432
|
+
gene_end - gene_start,
|
|
433
|
+
INTRON_HEIGHT,
|
|
434
|
+
facecolor=gene_col,
|
|
435
|
+
edgecolor=gene_col,
|
|
436
|
+
linewidth=0.5,
|
|
437
|
+
zorder=1,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Draw exons (thick rectangles)
|
|
441
|
+
for _, exon in gene_exons.iterrows():
|
|
442
|
+
exon_start = max(int(exon["start"]), start)
|
|
443
|
+
exon_end = min(int(exon["end"]), end)
|
|
444
|
+
backend.add_rectangle(
|
|
445
|
+
ax,
|
|
446
|
+
(exon_start, y_gene - EXON_HEIGHT / 2),
|
|
447
|
+
exon_end - exon_start,
|
|
448
|
+
EXON_HEIGHT,
|
|
449
|
+
facecolor=gene_col,
|
|
450
|
+
edgecolor=gene_col,
|
|
451
|
+
linewidth=0.5,
|
|
452
|
+
zorder=2,
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
# No exon data - draw full gene body as rectangle (fallback)
|
|
456
|
+
backend.add_rectangle(
|
|
457
|
+
ax,
|
|
458
|
+
(gene_start, y_gene - EXON_HEIGHT / 2),
|
|
459
|
+
gene_end - gene_start,
|
|
460
|
+
EXON_HEIGHT,
|
|
461
|
+
facecolor=gene_col,
|
|
462
|
+
edgecolor=gene_col,
|
|
463
|
+
linewidth=0.5,
|
|
464
|
+
zorder=2,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Add strand direction triangles (tip, center, tail)
|
|
468
|
+
if "strand" in gene.index:
|
|
469
|
+
strand = gene["strand"]
|
|
470
|
+
arrow_dir = 1 if strand == "+" else -1
|
|
471
|
+
|
|
472
|
+
# Triangle dimensions
|
|
473
|
+
tri_height = EXON_HEIGHT * 0.35
|
|
474
|
+
tri_width = region_width * 0.006
|
|
475
|
+
|
|
476
|
+
# Arrow positions: front, middle, back (tip positions)
|
|
477
|
+
tip_offset = tri_width / 2 # Tiny offset to keep tip inside gene
|
|
478
|
+
tail_offset = tri_width * 1.5 # Offset for tail arrow from gene start/end
|
|
479
|
+
gene_center = (gene_start + gene_end) / 2
|
|
480
|
+
if arrow_dir == 1: # Forward strand
|
|
481
|
+
arrow_tip_positions = [
|
|
482
|
+
gene_start + tail_offset, # Tail (tip inside gene)
|
|
483
|
+
gene_center + tri_width / 2, # Middle (arrow center at gene center)
|
|
484
|
+
gene_end - tip_offset, # Tip (near gene end)
|
|
485
|
+
]
|
|
486
|
+
arrow_color = "#000000" # Black for forward
|
|
487
|
+
else: # Reverse strand
|
|
488
|
+
arrow_tip_positions = [
|
|
489
|
+
gene_end - tail_offset, # Tail (tip inside gene)
|
|
490
|
+
gene_center - tri_width / 2, # Middle (arrow center at gene center)
|
|
491
|
+
gene_start + tip_offset, # Tip (near gene start)
|
|
492
|
+
]
|
|
493
|
+
arrow_color = "#333333" # Dark grey for reverse
|
|
494
|
+
|
|
495
|
+
for tip_x in arrow_tip_positions:
|
|
496
|
+
if arrow_dir == 1:
|
|
497
|
+
base_x = tip_x - tri_width
|
|
498
|
+
tri_points = [
|
|
499
|
+
[tip_x, y_gene], # Tip pointing right
|
|
500
|
+
[base_x, y_gene + tri_height],
|
|
501
|
+
[base_x, y_gene - tri_height],
|
|
502
|
+
]
|
|
503
|
+
else:
|
|
504
|
+
base_x = tip_x + tri_width
|
|
505
|
+
tri_points = [
|
|
506
|
+
[tip_x, y_gene], # Tip pointing left
|
|
507
|
+
[base_x, y_gene + tri_height],
|
|
508
|
+
[base_x, y_gene - tri_height],
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
backend.add_polygon(
|
|
512
|
+
ax,
|
|
513
|
+
tri_points,
|
|
514
|
+
facecolor=arrow_color,
|
|
515
|
+
edgecolor=arrow_color,
|
|
516
|
+
linewidth=0.5,
|
|
517
|
+
zorder=5,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Add gene name label in the gap above gene
|
|
521
|
+
if gene_name:
|
|
522
|
+
label_pos = (gene_start + gene_end) / 2
|
|
523
|
+
backend.add_text(
|
|
524
|
+
ax,
|
|
525
|
+
label_pos,
|
|
526
|
+
y_label,
|
|
527
|
+
gene_name,
|
|
528
|
+
fontsize=6,
|
|
529
|
+
ha="center",
|
|
530
|
+
va="bottom",
|
|
531
|
+
color="#000000",
|
|
532
|
+
)
|