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
|
@@ -71,8 +71,23 @@ class PlotlyBackend:
|
|
|
71
71
|
template="plotly_white",
|
|
72
72
|
)
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
|
|
74
|
+
# Style all panels for clean LocusZoom appearance
|
|
75
|
+
axis_style = dict(
|
|
76
|
+
showgrid=False,
|
|
77
|
+
showline=True,
|
|
78
|
+
linecolor="black",
|
|
79
|
+
ticks="outside",
|
|
80
|
+
minor_ticks="",
|
|
81
|
+
zeroline=False,
|
|
82
|
+
)
|
|
83
|
+
for row in range(1, n_panels + 1):
|
|
84
|
+
xaxis = self._axis_name("xaxis", row)
|
|
85
|
+
yaxis = self._axis_name("yaxis", row)
|
|
86
|
+
fig.update_layout(**{xaxis: axis_style, yaxis: axis_style})
|
|
87
|
+
|
|
88
|
+
# Return (fig, row) tuples for each panel
|
|
89
|
+
# This matches the expected ax parameter format for all methods
|
|
90
|
+
panel_refs = [(fig, row) for row in range(1, n_panels + 1)]
|
|
76
91
|
return fig, panel_refs
|
|
77
92
|
|
|
78
93
|
def scatter(
|
|
@@ -110,10 +125,13 @@ class PlotlyBackend:
|
|
|
110
125
|
hover_cols = hover_data.columns.tolist()
|
|
111
126
|
hovertemplate = "<b>%{customdata[0]}</b><br>"
|
|
112
127
|
for i, col in enumerate(hover_cols[1:], 1):
|
|
113
|
-
|
|
128
|
+
col_lower = col.lower()
|
|
129
|
+
if col_lower in ("p-value", "pval", "p_value"):
|
|
114
130
|
hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
|
|
115
|
-
elif
|
|
131
|
+
elif any(x in col_lower for x in ("r2", "r²", "ld")):
|
|
116
132
|
hovertemplate += f"{col}: %{{customdata[{i}]:.3f}}<br>"
|
|
133
|
+
elif "pos" in col_lower:
|
|
134
|
+
hovertemplate += f"{col}: %{{customdata[{i}]:,.0f}}<br>"
|
|
117
135
|
else:
|
|
118
136
|
hovertemplate += f"{col}: %{{customdata[{i}]}}<br>"
|
|
119
137
|
hovertemplate += "<extra></extra>"
|
|
@@ -221,6 +239,7 @@ class PlotlyBackend:
|
|
|
221
239
|
color: str = "grey",
|
|
222
240
|
linestyle: str = "--",
|
|
223
241
|
linewidth: float = 1.0,
|
|
242
|
+
alpha: float = 1.0,
|
|
224
243
|
zorder: int = 1,
|
|
225
244
|
) -> Any:
|
|
226
245
|
"""Add a horizontal line across the panel."""
|
|
@@ -234,6 +253,7 @@ class PlotlyBackend:
|
|
|
234
253
|
line_dash=dash,
|
|
235
254
|
line_color=color,
|
|
236
255
|
line_width=linewidth,
|
|
256
|
+
opacity=alpha,
|
|
237
257
|
row=row,
|
|
238
258
|
col=1,
|
|
239
259
|
)
|
|
@@ -299,26 +319,55 @@ class PlotlyBackend:
|
|
|
299
319
|
col=1,
|
|
300
320
|
)
|
|
301
321
|
|
|
322
|
+
def add_polygon(
|
|
323
|
+
self,
|
|
324
|
+
ax: Tuple[go.Figure, int],
|
|
325
|
+
points: List[List[float]],
|
|
326
|
+
facecolor: str = "blue",
|
|
327
|
+
edgecolor: str = "black",
|
|
328
|
+
linewidth: float = 0.5,
|
|
329
|
+
zorder: int = 2,
|
|
330
|
+
) -> Any:
|
|
331
|
+
"""Add a polygon (e.g., triangle for strand arrows) to the panel."""
|
|
332
|
+
fig, row = ax
|
|
333
|
+
|
|
334
|
+
# Build SVG path from points
|
|
335
|
+
path = f"M {points[0][0]} {points[0][1]}"
|
|
336
|
+
for px, py in points[1:]:
|
|
337
|
+
path += f" L {px} {py}"
|
|
338
|
+
path += " Z"
|
|
339
|
+
|
|
340
|
+
fig.add_shape(
|
|
341
|
+
type="path",
|
|
342
|
+
path=path,
|
|
343
|
+
fillcolor=facecolor,
|
|
344
|
+
line=dict(color=edgecolor, width=linewidth),
|
|
345
|
+
row=row,
|
|
346
|
+
col=1,
|
|
347
|
+
)
|
|
348
|
+
|
|
302
349
|
def set_xlim(self, ax: Tuple[go.Figure, int], left: float, right: float) -> None:
|
|
303
350
|
"""Set x-axis limits."""
|
|
304
351
|
fig, row = ax
|
|
305
|
-
|
|
306
|
-
fig.update_layout(**{xaxis: dict(range=[left, right])})
|
|
352
|
+
fig.update_layout(**{self._axis_name("xaxis", row): dict(range=[left, right])})
|
|
307
353
|
|
|
308
354
|
def set_ylim(self, ax: Tuple[go.Figure, int], bottom: float, top: float) -> None:
|
|
309
355
|
"""Set y-axis limits."""
|
|
310
356
|
fig, row = ax
|
|
311
|
-
|
|
312
|
-
fig.update_layout(**{yaxis: dict(range=[bottom, top])})
|
|
357
|
+
fig.update_layout(**{self._axis_name("yaxis", row): dict(range=[bottom, top])})
|
|
313
358
|
|
|
314
359
|
def set_xlabel(
|
|
315
360
|
self, ax: Tuple[go.Figure, int], label: str, fontsize: int = 12
|
|
316
361
|
) -> None:
|
|
317
362
|
"""Set x-axis label."""
|
|
318
363
|
fig, row = ax
|
|
319
|
-
|
|
364
|
+
label = self._convert_label(label)
|
|
320
365
|
fig.update_layout(
|
|
321
|
-
**{
|
|
366
|
+
**{
|
|
367
|
+
self._axis_name("xaxis", row): dict(
|
|
368
|
+
title=dict(text=label, font=dict(size=fontsize))
|
|
369
|
+
)
|
|
370
|
+
}
|
|
322
371
|
)
|
|
323
372
|
|
|
324
373
|
def set_ylabel(
|
|
@@ -326,11 +375,49 @@ class PlotlyBackend:
|
|
|
326
375
|
) -> None:
|
|
327
376
|
"""Set y-axis label."""
|
|
328
377
|
fig, row = ax
|
|
329
|
-
|
|
378
|
+
label = self._convert_label(label)
|
|
330
379
|
fig.update_layout(
|
|
331
|
-
**{
|
|
380
|
+
**{
|
|
381
|
+
self._axis_name("yaxis", row): dict(
|
|
382
|
+
title=dict(text=label, font=dict(size=fontsize))
|
|
383
|
+
)
|
|
384
|
+
}
|
|
332
385
|
)
|
|
333
386
|
|
|
387
|
+
def _axis_name(self, axis: str, row: int) -> str:
|
|
388
|
+
"""Get Plotly axis name for a given row.
|
|
389
|
+
|
|
390
|
+
Plotly names axes as 'xaxis', 'yaxis' for row 1, and
|
|
391
|
+
'xaxis2', 'yaxis2', etc. for subsequent rows.
|
|
392
|
+
"""
|
|
393
|
+
return f"{axis}{row}" if row > 1 else axis
|
|
394
|
+
|
|
395
|
+
def _get_legend_position(self, loc: str) -> dict:
|
|
396
|
+
"""Map matplotlib-style legend location to Plotly position dict."""
|
|
397
|
+
loc_map = {
|
|
398
|
+
"upper left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
|
|
399
|
+
"upper right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
|
|
400
|
+
"lower left": dict(x=0.01, y=0.01, xanchor="left", yanchor="bottom"),
|
|
401
|
+
"lower right": dict(x=0.99, y=0.01, xanchor="right", yanchor="bottom"),
|
|
402
|
+
}
|
|
403
|
+
return loc_map.get(loc, loc_map["upper left"])
|
|
404
|
+
|
|
405
|
+
def _convert_label(self, label: str) -> str:
|
|
406
|
+
"""Convert LaTeX-style labels to Unicode for Plotly display."""
|
|
407
|
+
conversions = [
|
|
408
|
+
(r"$-\log_{10}$ P", "-log₁₀(P)"),
|
|
409
|
+
(r"$-\log_{10}$", "-log₁₀"),
|
|
410
|
+
(r"\log_{10}", "log₁₀"),
|
|
411
|
+
(r"$r^2$", "r²"),
|
|
412
|
+
(r"$R^2$", "R²"),
|
|
413
|
+
]
|
|
414
|
+
for latex, unicode_str in conversions:
|
|
415
|
+
if latex in label:
|
|
416
|
+
label = label.replace(latex, unicode_str)
|
|
417
|
+
# Remove any remaining $ markers
|
|
418
|
+
label = label.replace("$", "")
|
|
419
|
+
return label
|
|
420
|
+
|
|
334
421
|
def set_title(
|
|
335
422
|
self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
|
|
336
423
|
) -> None:
|
|
@@ -360,6 +447,189 @@ class PlotlyBackend:
|
|
|
360
447
|
|
|
361
448
|
return (fig, row, secondary_y)
|
|
362
449
|
|
|
450
|
+
def line_secondary(
|
|
451
|
+
self,
|
|
452
|
+
ax: Tuple[go.Figure, int],
|
|
453
|
+
x: pd.Series,
|
|
454
|
+
y: pd.Series,
|
|
455
|
+
color: str = "blue",
|
|
456
|
+
linewidth: float = 1.5,
|
|
457
|
+
alpha: float = 1.0,
|
|
458
|
+
linestyle: str = "-",
|
|
459
|
+
label: Optional[str] = None,
|
|
460
|
+
yaxis_name: str = "y2",
|
|
461
|
+
) -> Any:
|
|
462
|
+
"""Create a line plot on secondary y-axis."""
|
|
463
|
+
fig, row = ax
|
|
464
|
+
|
|
465
|
+
dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
|
|
466
|
+
dash = dash_map.get(linestyle, "solid")
|
|
467
|
+
|
|
468
|
+
trace = go.Scatter(
|
|
469
|
+
x=x,
|
|
470
|
+
y=y,
|
|
471
|
+
mode="lines",
|
|
472
|
+
line=dict(color=color, width=linewidth, dash=dash),
|
|
473
|
+
opacity=alpha,
|
|
474
|
+
name=label or "",
|
|
475
|
+
showlegend=label is not None,
|
|
476
|
+
yaxis=yaxis_name,
|
|
477
|
+
hoverinfo="skip",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
fig.add_trace(trace, row=row, col=1)
|
|
481
|
+
return trace
|
|
482
|
+
|
|
483
|
+
def fill_between_secondary(
|
|
484
|
+
self,
|
|
485
|
+
ax: Tuple[go.Figure, int],
|
|
486
|
+
x: pd.Series,
|
|
487
|
+
y1: Union[float, pd.Series],
|
|
488
|
+
y2: Union[float, pd.Series],
|
|
489
|
+
color: str = "blue",
|
|
490
|
+
alpha: float = 0.3,
|
|
491
|
+
yaxis_name: str = "y2",
|
|
492
|
+
) -> Any:
|
|
493
|
+
"""Fill area between two y-values on secondary y-axis."""
|
|
494
|
+
fig, row = ax
|
|
495
|
+
|
|
496
|
+
if isinstance(y1, (int, float)):
|
|
497
|
+
y1 = pd.Series([y1] * len(x))
|
|
498
|
+
|
|
499
|
+
trace = go.Scatter(
|
|
500
|
+
x=pd.concat([x, x[::-1]]),
|
|
501
|
+
y=pd.concat([y2, y1[::-1]]),
|
|
502
|
+
fill="toself",
|
|
503
|
+
fillcolor=color,
|
|
504
|
+
opacity=alpha,
|
|
505
|
+
line=dict(width=0),
|
|
506
|
+
showlegend=False,
|
|
507
|
+
hoverinfo="skip",
|
|
508
|
+
yaxis=yaxis_name,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
fig.add_trace(trace, row=row, col=1)
|
|
512
|
+
return trace
|
|
513
|
+
|
|
514
|
+
def set_secondary_ylim(
|
|
515
|
+
self,
|
|
516
|
+
ax: Tuple[go.Figure, int],
|
|
517
|
+
bottom: float,
|
|
518
|
+
top: float,
|
|
519
|
+
yaxis_name: str = "y2",
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Set secondary y-axis limits."""
|
|
522
|
+
fig, row = ax
|
|
523
|
+
yaxis_key = (
|
|
524
|
+
"yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
|
|
525
|
+
)
|
|
526
|
+
fig.update_layout(**{yaxis_key: dict(range=[bottom, top])})
|
|
527
|
+
|
|
528
|
+
def set_secondary_ylabel(
|
|
529
|
+
self,
|
|
530
|
+
ax: Tuple[go.Figure, int],
|
|
531
|
+
label: str,
|
|
532
|
+
color: str = "black",
|
|
533
|
+
fontsize: int = 10,
|
|
534
|
+
yaxis_name: str = "y2",
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Set secondary y-axis label."""
|
|
537
|
+
fig, row = ax
|
|
538
|
+
label = self._convert_label(label)
|
|
539
|
+
yaxis_key = (
|
|
540
|
+
"yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
|
|
541
|
+
)
|
|
542
|
+
fig.update_layout(
|
|
543
|
+
**{
|
|
544
|
+
yaxis_key: dict(
|
|
545
|
+
title=dict(text=label, font=dict(size=fontsize, color=color)),
|
|
546
|
+
tickfont=dict(color=color),
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _get_panel_y_top(self, fig: go.Figure, row: int) -> float:
|
|
552
|
+
"""Get the top y-coordinate (in paper coords) for a subplot row.
|
|
553
|
+
|
|
554
|
+
Plotly subplots have y-axis domains that define their vertical position.
|
|
555
|
+
This returns the top of the domain for positioning legends.
|
|
556
|
+
"""
|
|
557
|
+
yaxis = getattr(fig.layout, self._axis_name("yaxis", row), None)
|
|
558
|
+
if yaxis and yaxis.domain:
|
|
559
|
+
return yaxis.domain[1]
|
|
560
|
+
return 0.99
|
|
561
|
+
|
|
562
|
+
def _add_legend_item(
|
|
563
|
+
self,
|
|
564
|
+
fig: go.Figure,
|
|
565
|
+
row: int,
|
|
566
|
+
name: str,
|
|
567
|
+
color: str,
|
|
568
|
+
symbol: str,
|
|
569
|
+
size: int,
|
|
570
|
+
legend_group: str,
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Add an invisible scatter trace for a legend entry."""
|
|
573
|
+
fig.add_trace(
|
|
574
|
+
go.Scatter(
|
|
575
|
+
x=[None],
|
|
576
|
+
y=[None],
|
|
577
|
+
mode="markers",
|
|
578
|
+
marker=dict(
|
|
579
|
+
symbol=symbol,
|
|
580
|
+
size=size,
|
|
581
|
+
color=color,
|
|
582
|
+
line=dict(color="black", width=0.5),
|
|
583
|
+
),
|
|
584
|
+
name=name,
|
|
585
|
+
showlegend=True,
|
|
586
|
+
legend=legend_group,
|
|
587
|
+
),
|
|
588
|
+
row=row,
|
|
589
|
+
col=1,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def _configure_legend(
|
|
593
|
+
self, fig: go.Figure, row: int, legend_key: str, title: str
|
|
594
|
+
) -> None:
|
|
595
|
+
"""Configure legend position and styling."""
|
|
596
|
+
y_pos = self._get_panel_y_top(fig, row)
|
|
597
|
+
fig.update_layout(
|
|
598
|
+
**{
|
|
599
|
+
legend_key: dict(
|
|
600
|
+
title=dict(text=title),
|
|
601
|
+
x=0.99,
|
|
602
|
+
y=y_pos,
|
|
603
|
+
xanchor="right",
|
|
604
|
+
yanchor="top",
|
|
605
|
+
bgcolor="rgba(255,255,255,0.9)",
|
|
606
|
+
bordercolor="black",
|
|
607
|
+
borderwidth=1,
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def add_ld_legend(
|
|
613
|
+
self,
|
|
614
|
+
ax: Tuple[go.Figure, int],
|
|
615
|
+
ld_bins: List[Tuple[float, str, str]],
|
|
616
|
+
lead_snp_color: str,
|
|
617
|
+
) -> None:
|
|
618
|
+
"""Add LD color legend using invisible scatter traces.
|
|
619
|
+
|
|
620
|
+
Uses Plotly's separate legend feature (legend="legend") so LD legend
|
|
621
|
+
can be positioned independently from eQTL and fine-mapping legends.
|
|
622
|
+
"""
|
|
623
|
+
fig, row = ax
|
|
624
|
+
|
|
625
|
+
self._add_legend_item(
|
|
626
|
+
fig, row, "Lead SNP", lead_snp_color, "diamond", 12, "legend"
|
|
627
|
+
)
|
|
628
|
+
for _, label, color in ld_bins:
|
|
629
|
+
self._add_legend_item(fig, row, label, color, "square", 10, "legend")
|
|
630
|
+
|
|
631
|
+
self._configure_legend(fig, row, "legend", "r²")
|
|
632
|
+
|
|
363
633
|
def add_legend(
|
|
364
634
|
self,
|
|
365
635
|
ax: Tuple[go.Figure, int],
|
|
@@ -374,16 +644,7 @@ class PlotlyBackend:
|
|
|
374
644
|
This method updates legend positioning.
|
|
375
645
|
"""
|
|
376
646
|
fig, _ = ax
|
|
377
|
-
|
|
378
|
-
# Map matplotlib locations to plotly
|
|
379
|
-
loc_map = {
|
|
380
|
-
"upper left": dict(x=0.01, y=0.99, xanchor="left", yanchor="top"),
|
|
381
|
-
"upper right": dict(x=0.99, y=0.99, xanchor="right", yanchor="top"),
|
|
382
|
-
"lower left": dict(x=0.01, y=0.01, xanchor="left", yanchor="bottom"),
|
|
383
|
-
"lower right": dict(x=0.99, y=0.01, xanchor="right", yanchor="bottom"),
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
legend_pos = loc_map.get(loc, loc_map["upper left"])
|
|
647
|
+
legend_pos = self._get_legend_position(loc)
|
|
387
648
|
fig.update_layout(
|
|
388
649
|
legend=dict(
|
|
389
650
|
**legend_pos,
|
|
@@ -403,23 +664,30 @@ class PlotlyBackend:
|
|
|
403
664
|
# No action needed - method exists for API compatibility
|
|
404
665
|
pass
|
|
405
666
|
|
|
406
|
-
def
|
|
407
|
-
"""
|
|
667
|
+
def hide_yaxis(self, ax: Tuple[go.Figure, int]) -> None:
|
|
668
|
+
"""Hide y-axis ticks, labels, line, and grid for gene track panels."""
|
|
408
669
|
fig, row = ax
|
|
409
|
-
xaxis = f"xaxis{row}" if row > 1 else "xaxis"
|
|
410
|
-
|
|
411
670
|
fig.update_layout(
|
|
412
671
|
**{
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
672
|
+
self._axis_name("yaxis", row): dict(
|
|
673
|
+
showticklabels=False,
|
|
674
|
+
showline=False,
|
|
675
|
+
showgrid=False,
|
|
676
|
+
ticks="",
|
|
417
677
|
)
|
|
418
678
|
}
|
|
419
679
|
)
|
|
420
680
|
|
|
421
|
-
|
|
422
|
-
|
|
681
|
+
def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
|
|
682
|
+
"""Format x-axis to show megabase values.
|
|
683
|
+
|
|
684
|
+
Stores the row for later tick formatting in finalize_layout.
|
|
685
|
+
"""
|
|
686
|
+
fig, row = ax
|
|
687
|
+
# Store that this axis needs Mb formatting
|
|
688
|
+
if not hasattr(fig, "_mb_format_rows"):
|
|
689
|
+
fig._mb_format_rows = []
|
|
690
|
+
fig._mb_format_rows.append(row)
|
|
423
691
|
|
|
424
692
|
def save(
|
|
425
693
|
self,
|
|
@@ -447,6 +715,73 @@ class PlotlyBackend:
|
|
|
447
715
|
"""Close the figure (no-op for plotly)."""
|
|
448
716
|
pass
|
|
449
717
|
|
|
718
|
+
def add_eqtl_legend(
|
|
719
|
+
self,
|
|
720
|
+
ax: Tuple[go.Figure, int],
|
|
721
|
+
eqtl_positive_bins: List[Tuple[float, float, str, str]],
|
|
722
|
+
eqtl_negative_bins: List[Tuple[float, float, str, str]],
|
|
723
|
+
) -> None:
|
|
724
|
+
"""Add eQTL effect size legend using invisible scatter traces.
|
|
725
|
+
|
|
726
|
+
Uses Plotly's separate legend feature (legend="legend2") so eQTL legend
|
|
727
|
+
is positioned independently below the LD legend.
|
|
728
|
+
"""
|
|
729
|
+
fig, row = ax
|
|
730
|
+
|
|
731
|
+
for _, _, label, color in eqtl_positive_bins:
|
|
732
|
+
self._add_legend_item(fig, row, label, color, "triangle-up", 10, "legend2")
|
|
733
|
+
for _, _, label, color in eqtl_negative_bins:
|
|
734
|
+
self._add_legend_item(
|
|
735
|
+
fig, row, label, color, "triangle-down", 10, "legend2"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
self._configure_legend(fig, row, "legend2", "eQTL effect")
|
|
739
|
+
|
|
740
|
+
def add_finemapping_legend(
|
|
741
|
+
self,
|
|
742
|
+
ax: Tuple[go.Figure, int],
|
|
743
|
+
credible_sets: List[int],
|
|
744
|
+
get_color_func: Any,
|
|
745
|
+
) -> None:
|
|
746
|
+
"""Add fine-mapping credible set legend using invisible scatter traces.
|
|
747
|
+
|
|
748
|
+
Uses Plotly's separate legend feature (legend="legend2") so fine-mapping
|
|
749
|
+
legend is positioned independently below the LD legend.
|
|
750
|
+
"""
|
|
751
|
+
if not credible_sets:
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
fig, row = ax
|
|
755
|
+
|
|
756
|
+
for cs_id in credible_sets:
|
|
757
|
+
self._add_legend_item(
|
|
758
|
+
fig, row, f"CS{cs_id}", get_color_func(cs_id), "circle", 10, "legend2"
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
self._configure_legend(fig, row, "legend2", "Credible sets")
|
|
762
|
+
|
|
763
|
+
def add_simple_legend(
|
|
764
|
+
self,
|
|
765
|
+
ax: Tuple[go.Figure, int],
|
|
766
|
+
label: str,
|
|
767
|
+
loc: str = "upper right",
|
|
768
|
+
) -> None:
|
|
769
|
+
"""Add simple legend positioning.
|
|
770
|
+
|
|
771
|
+
Plotly handles legends automatically from trace names.
|
|
772
|
+
This just positions the legend.
|
|
773
|
+
"""
|
|
774
|
+
fig, _ = ax
|
|
775
|
+
legend_pos = self._get_legend_position(loc)
|
|
776
|
+
fig.update_layout(
|
|
777
|
+
legend=dict(
|
|
778
|
+
**legend_pos,
|
|
779
|
+
bgcolor="rgba(255,255,255,0.9)",
|
|
780
|
+
bordercolor="black",
|
|
781
|
+
borderwidth=1,
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
450
785
|
def finalize_layout(
|
|
451
786
|
self,
|
|
452
787
|
fig: go.Figure,
|
|
@@ -456,7 +791,7 @@ class PlotlyBackend:
|
|
|
456
791
|
bottom: float = 0.1,
|
|
457
792
|
hspace: float = 0.08,
|
|
458
793
|
) -> None:
|
|
459
|
-
"""Adjust layout margins.
|
|
794
|
+
"""Adjust layout margins and apply Mb tick formatting.
|
|
460
795
|
|
|
461
796
|
Args:
|
|
462
797
|
fig: Figure object.
|
|
@@ -471,3 +806,58 @@ class PlotlyBackend:
|
|
|
471
806
|
b=int(bottom * fig.layout.height) if fig.layout.height else 80,
|
|
472
807
|
)
|
|
473
808
|
)
|
|
809
|
+
|
|
810
|
+
# Apply Mb tick formatting to marked axes
|
|
811
|
+
if hasattr(fig, "_mb_format_rows"):
|
|
812
|
+
import numpy as np
|
|
813
|
+
|
|
814
|
+
for row in fig._mb_format_rows:
|
|
815
|
+
xaxis_name = self._axis_name("xaxis", row)
|
|
816
|
+
xaxis = getattr(fig.layout, xaxis_name, None)
|
|
817
|
+
|
|
818
|
+
# Get x-range from the axis or compute from data
|
|
819
|
+
x_range = None
|
|
820
|
+
if xaxis and xaxis.range:
|
|
821
|
+
x_range = xaxis.range
|
|
822
|
+
else:
|
|
823
|
+
# Compute from trace data (filter out None values from legend traces)
|
|
824
|
+
x_vals = []
|
|
825
|
+
for trace in fig.data:
|
|
826
|
+
if hasattr(trace, "x") and trace.x is not None:
|
|
827
|
+
x_vals.extend([v for v in trace.x if v is not None])
|
|
828
|
+
if x_vals:
|
|
829
|
+
x_range = [min(x_vals), max(x_vals)]
|
|
830
|
+
|
|
831
|
+
if x_range:
|
|
832
|
+
x_min_mb, x_max_mb = x_range[0] / 1e6, x_range[1] / 1e6
|
|
833
|
+
span_mb = x_max_mb - x_min_mb
|
|
834
|
+
|
|
835
|
+
# Choose tick spacing based on range
|
|
836
|
+
if span_mb <= 0.5:
|
|
837
|
+
tick_step = 0.1
|
|
838
|
+
elif span_mb <= 2:
|
|
839
|
+
tick_step = 0.25
|
|
840
|
+
elif span_mb <= 5:
|
|
841
|
+
tick_step = 0.5
|
|
842
|
+
elif span_mb <= 20:
|
|
843
|
+
tick_step = 2
|
|
844
|
+
else:
|
|
845
|
+
tick_step = 5
|
|
846
|
+
|
|
847
|
+
# Generate ticks
|
|
848
|
+
first_tick = np.ceil(x_min_mb / tick_step) * tick_step
|
|
849
|
+
tickvals_mb = np.arange(
|
|
850
|
+
first_tick, x_max_mb + tick_step / 2, tick_step
|
|
851
|
+
)
|
|
852
|
+
tickvals_bp = [v * 1e6 for v in tickvals_mb]
|
|
853
|
+
ticktext = [f"{v:.2f}" for v in tickvals_mb]
|
|
854
|
+
|
|
855
|
+
fig.update_layout(
|
|
856
|
+
**{
|
|
857
|
+
xaxis_name: dict(
|
|
858
|
+
tickvals=tickvals_bp,
|
|
859
|
+
ticktext=ticktext,
|
|
860
|
+
ticksuffix=" Mb",
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
)
|
pylocuszoom/colors.py
CHANGED
|
@@ -236,4 +236,6 @@ def get_credible_set_color_palette(n_sets: int = 10) -> dict[int, str]:
|
|
|
236
236
|
>>> palette[1]
|
|
237
237
|
'#FF7F00'
|
|
238
238
|
"""
|
|
239
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)
|
|
241
|
+
}
|