pylocuszoom 0.6.0__py3-none-any.whl → 1.0.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 +34 -7
- pylocuszoom/backends/__init__.py +116 -17
- pylocuszoom/backends/base.py +363 -60
- pylocuszoom/backends/bokeh_backend.py +77 -15
- pylocuszoom/backends/hover.py +198 -0
- pylocuszoom/backends/matplotlib_backend.py +263 -3
- pylocuszoom/backends/plotly_backend.py +73 -16
- pylocuszoom/config.py +365 -0
- pylocuszoom/ensembl.py +476 -0
- pylocuszoom/eqtl.py +17 -25
- pylocuszoom/exceptions.py +33 -0
- pylocuszoom/finemapping.py +18 -32
- pylocuszoom/forest.py +10 -11
- pylocuszoom/gene_track.py +169 -142
- pylocuszoom/loaders.py +3 -1
- pylocuszoom/phewas.py +10 -11
- pylocuszoom/plotter.py +311 -277
- pylocuszoom/recombination.py +19 -3
- pylocuszoom/schemas.py +1 -6
- pylocuszoom/utils.py +54 -4
- pylocuszoom/validation.py +223 -0
- {pylocuszoom-0.6.0.dist-info → pylocuszoom-1.0.0.dist-info}/METADATA +82 -37
- pylocuszoom-1.0.0.dist-info/RECORD +31 -0
- pylocuszoom-0.6.0.dist-info/RECORD +0 -26
- {pylocuszoom-0.6.0.dist-info → pylocuszoom-1.0.0.dist-info}/WHEEL +0 -0
- {pylocuszoom-0.6.0.dist-info → pylocuszoom-1.0.0.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Hover data builder for consistent hover tooltip generation.
|
|
2
|
+
|
|
3
|
+
Provides a unified interface for building hover data across Plotly and Bokeh backends.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class HoverConfig:
|
|
14
|
+
"""Configuration for hover data column mapping.
|
|
15
|
+
|
|
16
|
+
Maps source DataFrame column names to standardized display names for tooltips.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
snp_col: Column name for SNP identifiers (displayed as "SNP").
|
|
20
|
+
pos_col: Column name for genomic position (displayed as "Position").
|
|
21
|
+
p_col: Column name for p-value (displayed as "P-value").
|
|
22
|
+
ld_col: Column name for LD/R-squared (displayed as "R²").
|
|
23
|
+
extra_cols: Additional columns to include, mapping source name to display name.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
snp_col: Optional[str] = None
|
|
27
|
+
pos_col: Optional[str] = None
|
|
28
|
+
p_col: Optional[str] = None
|
|
29
|
+
ld_col: Optional[str] = None
|
|
30
|
+
extra_cols: dict[str, str] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HoverDataBuilder:
|
|
34
|
+
"""Builder for constructing hover data and templates across backends.
|
|
35
|
+
|
|
36
|
+
Provides consistent hover tooltip generation for Plotly and Bokeh backends
|
|
37
|
+
with automatic format detection for common column types.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
FORMAT_SPECS: Format specifiers for different data types.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
FORMAT_SPECS = {
|
|
44
|
+
"p_value": ".2e",
|
|
45
|
+
"r2": ".3f",
|
|
46
|
+
"position": ",.0f",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Standard column mappings (source config attr -> display name)
|
|
50
|
+
_COLUMN_MAPPING = {
|
|
51
|
+
"snp_col": "SNP",
|
|
52
|
+
"pos_col": "Position",
|
|
53
|
+
"p_col": "P-value",
|
|
54
|
+
"ld_col": "R²",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: HoverConfig) -> None:
|
|
58
|
+
"""Initialize builder with column configuration.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config: HoverConfig with column name mappings.
|
|
62
|
+
"""
|
|
63
|
+
self.config = config
|
|
64
|
+
|
|
65
|
+
def build_dataframe(self, df: pd.DataFrame) -> Optional[pd.DataFrame]:
|
|
66
|
+
"""Build standardized hover DataFrame with renamed columns.
|
|
67
|
+
|
|
68
|
+
Extracts configured columns from the input DataFrame, renames them to
|
|
69
|
+
standardized display names, and returns a new DataFrame. Columns that
|
|
70
|
+
don't exist in the input are skipped gracefully.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
df: Input DataFrame containing hover data columns.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
DataFrame with renamed columns, or None if no configured columns exist.
|
|
77
|
+
"""
|
|
78
|
+
result_data = {}
|
|
79
|
+
|
|
80
|
+
# Process standard columns in order
|
|
81
|
+
for config_attr, display_name in self._COLUMN_MAPPING.items():
|
|
82
|
+
source_col = getattr(self.config, config_attr)
|
|
83
|
+
if source_col is not None and source_col in df.columns:
|
|
84
|
+
result_data[display_name] = df[source_col].values
|
|
85
|
+
|
|
86
|
+
# Process extra columns
|
|
87
|
+
for source_col, display_name in self.config.extra_cols.items():
|
|
88
|
+
if source_col in df.columns:
|
|
89
|
+
result_data[display_name] = df[source_col].values
|
|
90
|
+
|
|
91
|
+
if not result_data:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
return pd.DataFrame(result_data)
|
|
95
|
+
|
|
96
|
+
def build_plotly_template(self, hover_df: pd.DataFrame) -> str:
|
|
97
|
+
"""Generate Plotly hovertemplate string.
|
|
98
|
+
|
|
99
|
+
Creates a template string with appropriate format specifiers for each
|
|
100
|
+
column type. SNP appears first in bold, followed by other columns with
|
|
101
|
+
their values.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
hover_df: DataFrame returned by build_dataframe().
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Plotly hovertemplate string with customdata references.
|
|
108
|
+
"""
|
|
109
|
+
columns = hover_df.columns.tolist()
|
|
110
|
+
parts = []
|
|
111
|
+
|
|
112
|
+
for i, col in enumerate(columns):
|
|
113
|
+
if i == 0:
|
|
114
|
+
# First column (SNP) in bold
|
|
115
|
+
parts.append(f"<b>%{{customdata[{i}]}}</b>")
|
|
116
|
+
else:
|
|
117
|
+
fmt = self._detect_plotly_format(col)
|
|
118
|
+
if fmt:
|
|
119
|
+
parts.append(f"{col}: %{{customdata[{i}]:{fmt}}}")
|
|
120
|
+
else:
|
|
121
|
+
parts.append(f"{col}: %{{customdata[{i}]}}")
|
|
122
|
+
|
|
123
|
+
parts.append("<extra></extra>")
|
|
124
|
+
return "<br>".join(parts)
|
|
125
|
+
|
|
126
|
+
def build_bokeh_tooltips(self, hover_df: pd.DataFrame) -> list[tuple[str, str]]:
|
|
127
|
+
"""Generate Bokeh tooltips list.
|
|
128
|
+
|
|
129
|
+
Creates a list of (name, format) tuples for Bokeh HoverTool configuration.
|
|
130
|
+
Each tuple contains the display name and the Bokeh format string with
|
|
131
|
+
appropriate specifiers.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
hover_df: DataFrame returned by build_dataframe().
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of (name, format_string) tuples for Bokeh tooltips.
|
|
138
|
+
"""
|
|
139
|
+
tooltips = []
|
|
140
|
+
|
|
141
|
+
for col in hover_df.columns:
|
|
142
|
+
fmt = self._detect_bokeh_format(col)
|
|
143
|
+
if fmt:
|
|
144
|
+
tooltips.append((col, f"@{{{col}}}{{{fmt}}}"))
|
|
145
|
+
else:
|
|
146
|
+
tooltips.append((col, f"@{col}"))
|
|
147
|
+
|
|
148
|
+
return tooltips
|
|
149
|
+
|
|
150
|
+
def _detect_plotly_format(self, col_name: str) -> Optional[str]:
|
|
151
|
+
"""Detect appropriate Plotly format specifier for column.
|
|
152
|
+
|
|
153
|
+
Uses heuristics based on column name to determine formatting:
|
|
154
|
+
- P-value columns: scientific notation (.2e)
|
|
155
|
+
- R²/LD columns: 3 decimal places (.3f)
|
|
156
|
+
- Position columns: comma-separated (.0f)
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
col_name: Display name of the column.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Plotly format specifier string, or None for default format.
|
|
163
|
+
"""
|
|
164
|
+
col_lower = col_name.lower()
|
|
165
|
+
|
|
166
|
+
if col_lower in ("p-value", "pval", "p_value"):
|
|
167
|
+
return self.FORMAT_SPECS["p_value"]
|
|
168
|
+
elif any(x in col_lower for x in ("r2", "r²", "ld")):
|
|
169
|
+
return self.FORMAT_SPECS["r2"]
|
|
170
|
+
elif "pos" in col_lower:
|
|
171
|
+
return self.FORMAT_SPECS["position"]
|
|
172
|
+
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _detect_bokeh_format(self, col_name: str) -> Optional[str]:
|
|
176
|
+
"""Detect appropriate Bokeh format code for column.
|
|
177
|
+
|
|
178
|
+
Uses heuristics based on column name to determine formatting:
|
|
179
|
+
- P-value columns: scientific notation (0.2e)
|
|
180
|
+
- R²/LD columns: 3 decimal places (0.3f)
|
|
181
|
+
- Position columns: comma-separated (0,0)
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
col_name: Display name of the column.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Bokeh format code string, or None for default format.
|
|
188
|
+
"""
|
|
189
|
+
col_lower = col_name.lower()
|
|
190
|
+
|
|
191
|
+
if col_lower in ("p-value", "pval", "p_value"):
|
|
192
|
+
return "0.2e"
|
|
193
|
+
elif any(x in col_lower for x in ("r2", "r²", "ld")):
|
|
194
|
+
return "0.3f"
|
|
195
|
+
elif "pos" in col_lower:
|
|
196
|
+
return "0,0"
|
|
197
|
+
|
|
198
|
+
return None
|
|
@@ -12,17 +12,40 @@ from matplotlib.figure import Figure
|
|
|
12
12
|
from matplotlib.patches import Polygon, Rectangle
|
|
13
13
|
from matplotlib.ticker import FuncFormatter, MaxNLocator
|
|
14
14
|
|
|
15
|
+
from . import register_backend
|
|
15
16
|
|
|
17
|
+
|
|
18
|
+
@register_backend("matplotlib")
|
|
16
19
|
class MatplotlibBackend:
|
|
17
20
|
"""Matplotlib backend for static plot generation.
|
|
18
21
|
|
|
19
22
|
This is the default backend, producing publication-quality static plots
|
|
20
23
|
suitable for papers and presentations.
|
|
24
|
+
|
|
25
|
+
Capability Properties:
|
|
26
|
+
supports_snp_labels: True - uses adjustText for automatic label positioning.
|
|
27
|
+
supports_hover: False - static plots don't support hover tooltips.
|
|
28
|
+
supports_secondary_axis: True - supports twin y-axis via twinx().
|
|
21
29
|
"""
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
# =========================================================================
|
|
32
|
+
# Capability Properties
|
|
33
|
+
# =========================================================================
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def supports_snp_labels(self) -> bool:
|
|
37
|
+
"""Matplotlib supports SNP labels via adjustText."""
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def supports_hover(self) -> bool:
|
|
42
|
+
"""Matplotlib does not support hover tooltips."""
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def supports_secondary_axis(self) -> bool:
|
|
47
|
+
"""Matplotlib supports twin y-axis."""
|
|
48
|
+
return True
|
|
26
49
|
|
|
27
50
|
def create_figure(
|
|
28
51
|
self,
|
|
@@ -164,6 +187,67 @@ class MatplotlibBackend:
|
|
|
164
187
|
x, y, text, fontsize=fontsize, ha=ha, va=va, rotation=rotation, color=color
|
|
165
188
|
)
|
|
166
189
|
|
|
190
|
+
def add_panel_label(
|
|
191
|
+
self,
|
|
192
|
+
ax: Axes,
|
|
193
|
+
label: str,
|
|
194
|
+
x_frac: float = 0.02,
|
|
195
|
+
y_frac: float = 0.95,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Add label text at fractional position in panel.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
ax: Matplotlib axes.
|
|
201
|
+
label: Label text (e.g., "A", "B").
|
|
202
|
+
x_frac: Horizontal position as fraction of axes (0-1).
|
|
203
|
+
y_frac: Vertical position as fraction of axes (0-1).
|
|
204
|
+
"""
|
|
205
|
+
ax.annotate(
|
|
206
|
+
label,
|
|
207
|
+
xy=(x_frac, y_frac),
|
|
208
|
+
xycoords="axes fraction",
|
|
209
|
+
fontsize=10,
|
|
210
|
+
fontweight="bold",
|
|
211
|
+
ha="left",
|
|
212
|
+
va="top",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def add_snp_labels(
|
|
216
|
+
self,
|
|
217
|
+
ax: Axes,
|
|
218
|
+
df: pd.DataFrame,
|
|
219
|
+
pos_col: str,
|
|
220
|
+
neglog10p_col: str,
|
|
221
|
+
rs_col: str,
|
|
222
|
+
label_top_n: int,
|
|
223
|
+
genes_df: Optional[pd.DataFrame],
|
|
224
|
+
chrom: int,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Add SNP labels using adjustText.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
ax: Matplotlib axes.
|
|
230
|
+
df: DataFrame with SNP data.
|
|
231
|
+
pos_col: Column name for position.
|
|
232
|
+
neglog10p_col: Column name for -log10(p-value).
|
|
233
|
+
rs_col: Column name for SNP ID.
|
|
234
|
+
label_top_n: Number of top SNPs to label.
|
|
235
|
+
genes_df: Gene annotations (unused, for signature compatibility).
|
|
236
|
+
chrom: Chromosome number (unused, for signature compatibility).
|
|
237
|
+
"""
|
|
238
|
+
from ..labels import add_snp_labels as _add_snp_labels
|
|
239
|
+
|
|
240
|
+
_add_snp_labels(
|
|
241
|
+
ax,
|
|
242
|
+
df,
|
|
243
|
+
pos_col=pos_col,
|
|
244
|
+
neglog10p_col=neglog10p_col,
|
|
245
|
+
rs_col=rs_col,
|
|
246
|
+
label_top_n=label_top_n,
|
|
247
|
+
genes_df=genes_df,
|
|
248
|
+
chrom=chrom,
|
|
249
|
+
)
|
|
250
|
+
|
|
167
251
|
def add_rectangle(
|
|
168
252
|
self,
|
|
169
253
|
ax: Axes,
|
|
@@ -225,6 +309,17 @@ class MatplotlibBackend:
|
|
|
225
309
|
"""Set y-axis label."""
|
|
226
310
|
ax.set_ylabel(label, fontsize=fontsize)
|
|
227
311
|
|
|
312
|
+
def set_yticks(
|
|
313
|
+
self,
|
|
314
|
+
ax: Axes,
|
|
315
|
+
positions: List[float],
|
|
316
|
+
labels: List[str],
|
|
317
|
+
fontsize: int = 10,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Set y-axis tick positions and labels."""
|
|
320
|
+
ax.set_yticks(positions)
|
|
321
|
+
ax.set_yticklabels(labels, fontsize=fontsize)
|
|
322
|
+
|
|
228
323
|
def set_title(self, ax: Axes, title: str, fontsize: int = 14) -> None:
|
|
229
324
|
"""Set panel title."""
|
|
230
325
|
ax.set_title(
|
|
@@ -238,6 +333,119 @@ class MatplotlibBackend:
|
|
|
238
333
|
"""Create a secondary y-axis sharing the same x-axis."""
|
|
239
334
|
return ax.twinx()
|
|
240
335
|
|
|
336
|
+
def line_secondary(
|
|
337
|
+
self,
|
|
338
|
+
ax: Axes,
|
|
339
|
+
x: pd.Series,
|
|
340
|
+
y: pd.Series,
|
|
341
|
+
color: str = "blue",
|
|
342
|
+
linewidth: float = 1.5,
|
|
343
|
+
alpha: float = 1.0,
|
|
344
|
+
linestyle: str = "-",
|
|
345
|
+
label: Optional[str] = None,
|
|
346
|
+
yaxis_name: Any = None,
|
|
347
|
+
) -> Any:
|
|
348
|
+
"""Create line on secondary y-axis.
|
|
349
|
+
|
|
350
|
+
For matplotlib, the ax should already be a twin axis from create_twin_axis().
|
|
351
|
+
The yaxis_name parameter is ignored (provided for interface compatibility).
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
ax: Secondary axes from create_twin_axis().
|
|
355
|
+
x: X-axis values.
|
|
356
|
+
y: Y-axis values.
|
|
357
|
+
color: Line color.
|
|
358
|
+
linewidth: Line width.
|
|
359
|
+
alpha: Transparency.
|
|
360
|
+
linestyle: Line style.
|
|
361
|
+
label: Legend label.
|
|
362
|
+
yaxis_name: Ignored for matplotlib.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
The line object.
|
|
366
|
+
"""
|
|
367
|
+
return self.line(
|
|
368
|
+
ax,
|
|
369
|
+
x,
|
|
370
|
+
y,
|
|
371
|
+
color=color,
|
|
372
|
+
linewidth=linewidth,
|
|
373
|
+
alpha=alpha,
|
|
374
|
+
linestyle=linestyle,
|
|
375
|
+
label=label,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def fill_between_secondary(
|
|
379
|
+
self,
|
|
380
|
+
ax: Axes,
|
|
381
|
+
x: pd.Series,
|
|
382
|
+
y1: Union[float, pd.Series],
|
|
383
|
+
y2: Union[float, pd.Series],
|
|
384
|
+
color: str = "blue",
|
|
385
|
+
alpha: float = 0.3,
|
|
386
|
+
yaxis_name: Any = None,
|
|
387
|
+
) -> Any:
|
|
388
|
+
"""Fill area on secondary y-axis.
|
|
389
|
+
|
|
390
|
+
For matplotlib, the ax should already be a twin axis from create_twin_axis().
|
|
391
|
+
The yaxis_name parameter is ignored (provided for interface compatibility).
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
ax: Secondary axes from create_twin_axis().
|
|
395
|
+
x: X-axis values.
|
|
396
|
+
y1: Lower y boundary.
|
|
397
|
+
y2: Upper y boundary.
|
|
398
|
+
color: Fill color.
|
|
399
|
+
alpha: Transparency.
|
|
400
|
+
yaxis_name: Ignored for matplotlib.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
The fill object.
|
|
404
|
+
"""
|
|
405
|
+
return self.fill_between(ax, x, y1, y2, color=color, alpha=alpha)
|
|
406
|
+
|
|
407
|
+
def set_secondary_ylim(
|
|
408
|
+
self,
|
|
409
|
+
ax: Axes,
|
|
410
|
+
bottom: float,
|
|
411
|
+
top: float,
|
|
412
|
+
yaxis_name: Any = None,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Set secondary y-axis limits.
|
|
415
|
+
|
|
416
|
+
For matplotlib, the ax should already be a twin axis from create_twin_axis().
|
|
417
|
+
The yaxis_name parameter is ignored (provided for interface compatibility).
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
ax: Secondary axes from create_twin_axis().
|
|
421
|
+
bottom: Minimum y value.
|
|
422
|
+
top: Maximum y value.
|
|
423
|
+
yaxis_name: Ignored for matplotlib.
|
|
424
|
+
"""
|
|
425
|
+
self.set_ylim(ax, bottom, top)
|
|
426
|
+
|
|
427
|
+
def set_secondary_ylabel(
|
|
428
|
+
self,
|
|
429
|
+
ax: Axes,
|
|
430
|
+
label: str,
|
|
431
|
+
color: str = "black",
|
|
432
|
+
fontsize: int = 10,
|
|
433
|
+
yaxis_name: Any = None,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Set secondary y-axis label.
|
|
436
|
+
|
|
437
|
+
For matplotlib, the ax should already be a twin axis from create_twin_axis().
|
|
438
|
+
The yaxis_name parameter is ignored (provided for interface compatibility).
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
ax: Secondary axes from create_twin_axis().
|
|
442
|
+
label: Label text.
|
|
443
|
+
color: Label color.
|
|
444
|
+
fontsize: Font size.
|
|
445
|
+
yaxis_name: Ignored for matplotlib.
|
|
446
|
+
"""
|
|
447
|
+
ax.set_ylabel(label, fontsize=fontsize, color=color)
|
|
448
|
+
|
|
241
449
|
def add_legend(
|
|
242
450
|
self,
|
|
243
451
|
ax: Axes,
|
|
@@ -266,6 +474,10 @@ class MatplotlibBackend:
|
|
|
266
474
|
for spine in spines:
|
|
267
475
|
ax.spines[spine].set_visible(False)
|
|
268
476
|
|
|
477
|
+
def hide_yaxis(self, ax: Axes) -> None:
|
|
478
|
+
"""Hide y-axis ticks, labels, and line."""
|
|
479
|
+
ax.yaxis.set_visible(False)
|
|
480
|
+
|
|
269
481
|
def format_xaxis_mb(self, ax: Axes) -> None:
|
|
270
482
|
"""Format x-axis to show megabase values."""
|
|
271
483
|
ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x / 1e6:.2f}"))
|
|
@@ -394,6 +606,54 @@ class MatplotlibBackend:
|
|
|
394
606
|
"""Add simple legend for labeled scatter data."""
|
|
395
607
|
ax.legend(loc=loc, fontsize=9)
|
|
396
608
|
|
|
609
|
+
def add_ld_legend(
|
|
610
|
+
self,
|
|
611
|
+
ax: Axes,
|
|
612
|
+
ld_bins: List[Tuple[float, str, str]],
|
|
613
|
+
lead_snp_color: str,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Add LD color legend using matplotlib patches.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
ax: Matplotlib axes.
|
|
619
|
+
ld_bins: List of (threshold, label, color) tuples defining LD bins.
|
|
620
|
+
lead_snp_color: Color for lead SNP marker in legend.
|
|
621
|
+
"""
|
|
622
|
+
from matplotlib.lines import Line2D
|
|
623
|
+
from matplotlib.patches import Patch
|
|
624
|
+
|
|
625
|
+
from ..colors import get_ld_color_palette
|
|
626
|
+
|
|
627
|
+
palette = get_ld_color_palette()
|
|
628
|
+
legend_elements = [
|
|
629
|
+
Line2D(
|
|
630
|
+
[0],
|
|
631
|
+
[0],
|
|
632
|
+
marker="D",
|
|
633
|
+
color="w",
|
|
634
|
+
markerfacecolor=lead_snp_color,
|
|
635
|
+
markeredgecolor="black",
|
|
636
|
+
markersize=6,
|
|
637
|
+
label="Lead SNP",
|
|
638
|
+
),
|
|
639
|
+
]
|
|
640
|
+
for _threshold, label, _color in ld_bins:
|
|
641
|
+
legend_elements.append(
|
|
642
|
+
Patch(facecolor=palette[label], edgecolor="black", label=label)
|
|
643
|
+
)
|
|
644
|
+
ax.legend(
|
|
645
|
+
handles=legend_elements,
|
|
646
|
+
loc="upper right",
|
|
647
|
+
fontsize=9,
|
|
648
|
+
frameon=True,
|
|
649
|
+
framealpha=0.9,
|
|
650
|
+
title=r"$r^2$",
|
|
651
|
+
title_fontsize=10,
|
|
652
|
+
handlelength=1.5,
|
|
653
|
+
handleheight=1.0,
|
|
654
|
+
labelspacing=0.4,
|
|
655
|
+
)
|
|
656
|
+
|
|
397
657
|
def axvline(
|
|
398
658
|
self,
|
|
399
659
|
ax: Axes,
|
|
@@ -9,7 +9,10 @@ import pandas as pd
|
|
|
9
9
|
import plotly.graph_objects as go
|
|
10
10
|
from plotly.subplots import make_subplots
|
|
11
11
|
|
|
12
|
+
from . import convert_latex_to_unicode, register_backend
|
|
12
13
|
|
|
14
|
+
|
|
15
|
+
@register_backend("plotly")
|
|
13
16
|
class PlotlyBackend:
|
|
14
17
|
"""Plotly backend for interactive plot generation.
|
|
15
18
|
|
|
@@ -35,9 +38,20 @@ class PlotlyBackend:
|
|
|
35
38
|
"-.": "dashdot",
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
@property
|
|
42
|
+
def supports_snp_labels(self) -> bool:
|
|
43
|
+
"""Plotly uses hover tooltips instead of labels."""
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def supports_hover(self) -> bool:
|
|
48
|
+
"""Plotly supports hover tooltips."""
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def supports_secondary_axis(self) -> bool:
|
|
53
|
+
"""Plotly supports secondary y-axis."""
|
|
54
|
+
return True
|
|
41
55
|
|
|
42
56
|
def create_figure(
|
|
43
57
|
self,
|
|
@@ -383,6 +397,26 @@ class PlotlyBackend:
|
|
|
383
397
|
}
|
|
384
398
|
)
|
|
385
399
|
|
|
400
|
+
def set_yticks(
|
|
401
|
+
self,
|
|
402
|
+
ax: Tuple[go.Figure, int],
|
|
403
|
+
positions: List[float],
|
|
404
|
+
labels: List[str],
|
|
405
|
+
fontsize: int = 10,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Set y-axis tick positions and labels."""
|
|
408
|
+
fig, row = ax
|
|
409
|
+
fig.update_layout(
|
|
410
|
+
**{
|
|
411
|
+
self._axis_name("yaxis", row): dict(
|
|
412
|
+
tickmode="array",
|
|
413
|
+
tickvals=positions,
|
|
414
|
+
ticktext=labels,
|
|
415
|
+
tickfont=dict(size=fontsize),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
|
|
386
420
|
def _axis_name(self, axis: str, row: int) -> str:
|
|
387
421
|
"""Get Plotly axis name for a given row.
|
|
388
422
|
|
|
@@ -403,19 +437,7 @@ class PlotlyBackend:
|
|
|
403
437
|
|
|
404
438
|
def _convert_label(self, label: str) -> str:
|
|
405
439
|
"""Convert LaTeX-style labels to Unicode for Plotly display."""
|
|
406
|
-
|
|
407
|
-
(r"$-\log_{10}$ P", "-log₁₀(P)"),
|
|
408
|
-
(r"$-\log_{10}$", "-log₁₀"),
|
|
409
|
-
(r"\log_{10}", "log₁₀"),
|
|
410
|
-
(r"$r^2$", "r²"),
|
|
411
|
-
(r"$R^2$", "R²"),
|
|
412
|
-
]
|
|
413
|
-
for latex, unicode_str in conversions:
|
|
414
|
-
if latex in label:
|
|
415
|
-
label = label.replace(latex, unicode_str)
|
|
416
|
-
# Remove any remaining $ markers
|
|
417
|
-
label = label.replace("$", "")
|
|
418
|
-
return label
|
|
440
|
+
return convert_latex_to_unicode(label)
|
|
419
441
|
|
|
420
442
|
def set_title(
|
|
421
443
|
self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
|
|
@@ -606,6 +628,41 @@ class PlotlyBackend:
|
|
|
606
628
|
}
|
|
607
629
|
)
|
|
608
630
|
|
|
631
|
+
def add_snp_labels(
|
|
632
|
+
self,
|
|
633
|
+
ax: Tuple[go.Figure, int],
|
|
634
|
+
df: pd.DataFrame,
|
|
635
|
+
pos_col: str,
|
|
636
|
+
neglog10p_col: str,
|
|
637
|
+
rs_col: str,
|
|
638
|
+
label_top_n: int,
|
|
639
|
+
genes_df: Optional[pd.DataFrame],
|
|
640
|
+
chrom: int,
|
|
641
|
+
) -> None:
|
|
642
|
+
"""No-op: Plotly uses hover tooltips instead of text labels."""
|
|
643
|
+
pass
|
|
644
|
+
|
|
645
|
+
def add_panel_label(
|
|
646
|
+
self,
|
|
647
|
+
ax: Tuple[go.Figure, int],
|
|
648
|
+
label: str,
|
|
649
|
+
x_frac: float = 0.02,
|
|
650
|
+
y_frac: float = 0.95,
|
|
651
|
+
) -> None:
|
|
652
|
+
"""Add label text at fractional position in panel."""
|
|
653
|
+
fig, row = ax
|
|
654
|
+
fig.add_annotation(
|
|
655
|
+
text=f"<b>{label}</b>",
|
|
656
|
+
xref="x domain",
|
|
657
|
+
yref="y domain",
|
|
658
|
+
x=x_frac,
|
|
659
|
+
y=y_frac,
|
|
660
|
+
showarrow=False,
|
|
661
|
+
font=dict(size=12),
|
|
662
|
+
row=row,
|
|
663
|
+
col=1,
|
|
664
|
+
)
|
|
665
|
+
|
|
609
666
|
def add_ld_legend(
|
|
610
667
|
self,
|
|
611
668
|
ax: Tuple[go.Figure, int],
|