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.
@@ -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
- def __init__(self) -> None:
24
- """Initialize the matplotlib backend."""
25
- pass
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
- def __init__(self) -> None:
39
- """Initialize the plotly backend."""
40
- pass
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
- conversions = [
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],