pylocuszoom 0.5.0__py3-none-any.whl → 0.8.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.
@@ -11,7 +11,10 @@ from bokeh.layouts import column
11
11
  from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
12
12
  from bokeh.plotting import figure
13
13
 
14
+ from . import convert_latex_to_unicode, register_backend
14
15
 
16
+
17
+ @register_backend("bokeh")
15
18
  class BokehBackend:
16
19
  """Bokeh backend for interactive plot generation.
17
20
 
@@ -19,15 +22,35 @@ class BokehBackend:
19
22
  applications and dashboards.
20
23
  """
21
24
 
22
- def __init__(self) -> None:
23
- """Initialize the bokeh backend."""
24
- self._marker_map = {
25
- "o": "circle",
26
- "D": "diamond",
27
- "s": "square",
28
- "^": "triangle",
29
- "v": "inverted_triangle",
30
- }
25
+ # Class constants for style mappings
26
+ _MARKER_MAP = {
27
+ "o": "circle",
28
+ "D": "diamond",
29
+ "s": "square",
30
+ "^": "triangle",
31
+ "v": "inverted_triangle",
32
+ }
33
+ _DASH_MAP = {
34
+ "-": "solid",
35
+ "--": "dashed",
36
+ ":": "dotted",
37
+ "-.": "dashdot",
38
+ }
39
+
40
+ @property
41
+ def supports_snp_labels(self) -> bool:
42
+ """Bokeh uses hover tooltips instead of labels."""
43
+ return False
44
+
45
+ @property
46
+ def supports_hover(self) -> bool:
47
+ """Bokeh supports hover tooltips."""
48
+ return True
49
+
50
+ @property
51
+ def supports_secondary_axis(self) -> bool:
52
+ """Bokeh supports secondary y-axis."""
53
+ return True
31
54
 
32
55
  def create_figure(
33
56
  self,
@@ -131,7 +154,7 @@ class BokehBackend:
131
154
  source = ColumnDataSource(data)
132
155
 
133
156
  # Get marker type for scatter()
134
- marker_type = self._marker_map.get(marker, "circle")
157
+ marker_type = self._MARKER_MAP.get(marker, "circle")
135
158
 
136
159
  # Create scatter using scatter() method (Bokeh 3.4+ preferred API)
137
160
  scatter_kwargs = {
@@ -171,14 +194,7 @@ class BokehBackend:
171
194
  label: Optional[str] = None,
172
195
  ) -> Any:
173
196
  """Create a line plot on the given figure."""
174
- # Convert linestyle
175
- dash_map = {
176
- "-": "solid",
177
- "--": "dashed",
178
- ":": "dotted",
179
- "-.": "dashdot",
180
- }
181
- line_dash = dash_map.get(linestyle, "solid")
197
+ line_dash = self._DASH_MAP.get(linestyle, "solid")
182
198
 
183
199
  line_kwargs = {
184
200
  "line_color": color,
@@ -233,8 +249,7 @@ class BokehBackend:
233
249
  zorder: int = 1,
234
250
  ) -> Any:
235
251
  """Add a horizontal line across the figure."""
236
- dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
237
- line_dash = dash_map.get(linestyle, "dashed")
252
+ line_dash = self._DASH_MAP.get(linestyle, "dashed")
238
253
 
239
254
  span = Span(
240
255
  location=y,
@@ -355,6 +370,18 @@ class BokehBackend:
355
370
  ax.yaxis.axis_label = label
356
371
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
357
372
 
373
+ def set_yticks(
374
+ self,
375
+ ax: figure,
376
+ positions: List[float],
377
+ labels: List[str],
378
+ fontsize: int = 10,
379
+ ) -> None:
380
+ """Set y-axis tick positions and labels."""
381
+ ax.yaxis.ticker = positions
382
+ ax.yaxis.major_label_overrides = dict(zip(positions, labels))
383
+ ax.yaxis.major_label_text_font_size = f"{fontsize}pt"
384
+
358
385
  def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
359
386
  """Map matplotlib-style legend location to Bokeh location."""
360
387
  loc_map = {
@@ -367,18 +394,7 @@ class BokehBackend:
367
394
 
368
395
  def _convert_label(self, label: str) -> str:
369
396
  """Convert LaTeX-style labels to Unicode for Bokeh display."""
370
- conversions = [
371
- (r"$-\log_{10}$ P", "-log₁₀(P)"),
372
- (r"$-\log_{10}$", "-log₁₀"),
373
- (r"\log_{10}", "log₁₀"),
374
- (r"$r^2$", "r²"),
375
- (r"$R^2$", "R²"),
376
- ]
377
- for latex, unicode_str in conversions:
378
- if latex in label:
379
- label = label.replace(latex, unicode_str)
380
- label = label.replace("$", "")
381
- return label
397
+ return convert_latex_to_unicode(label)
382
398
 
383
399
  def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
384
400
  """Set figure title."""
@@ -411,8 +427,7 @@ class BokehBackend:
411
427
  yaxis_name: str = "secondary",
412
428
  ) -> Any:
413
429
  """Create a line plot on secondary y-axis."""
414
- dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
415
- line_dash = dash_map.get(linestyle, "solid")
430
+ line_dash = self._DASH_MAP.get(linestyle, "solid")
416
431
 
417
432
  return ax.line(
418
433
  x.values,
@@ -489,6 +504,53 @@ class BokehBackend:
489
504
  renderer.major_label_text_color = color
490
505
  break
491
506
 
507
+ def add_snp_labels(
508
+ self,
509
+ ax: figure,
510
+ df: pd.DataFrame,
511
+ pos_col: str,
512
+ neglog10p_col: str,
513
+ rs_col: str,
514
+ label_top_n: int,
515
+ genes_df: Optional[pd.DataFrame],
516
+ chrom: int,
517
+ ) -> None:
518
+ """No-op: Bokeh uses hover tooltips instead of text labels."""
519
+ pass
520
+
521
+ def add_panel_label(
522
+ self,
523
+ ax: figure,
524
+ label: str,
525
+ x_frac: float = 0.02,
526
+ y_frac: float = 0.95,
527
+ ) -> None:
528
+ """Add label text at fractional position in panel."""
529
+ from bokeh.models import Label
530
+
531
+ # Convert fraction to data coordinates using axis ranges
532
+ x_range = ax.x_range
533
+ y_range = ax.y_range
534
+ x = (
535
+ x_range.start + x_frac * (x_range.end - x_range.start)
536
+ if hasattr(x_range, "start") and x_range.start is not None
537
+ else 0
538
+ )
539
+ y = (
540
+ y_range.start + y_frac * (y_range.end - y_range.start)
541
+ if hasattr(y_range, "start") and y_range.start is not None
542
+ else 0
543
+ )
544
+
545
+ label_obj = Label(
546
+ x=x,
547
+ y=y,
548
+ text=label,
549
+ text_font_size="12px",
550
+ text_font_style="bold",
551
+ )
552
+ ax.add_layout(label_obj)
553
+
492
554
  def _ensure_legend_range(self, ax: figure) -> Any:
493
555
  """Ensure legend range exists and return a dummy data source.
494
556
 
@@ -683,6 +745,102 @@ class BokehBackend:
683
745
  ax.legend.background_fill_alpha = 0.9
684
746
  ax.legend.border_line_color = "black"
685
747
 
748
+ def axvline(
749
+ self,
750
+ ax: figure,
751
+ x: float,
752
+ color: str = "grey",
753
+ linestyle: str = "--",
754
+ linewidth: float = 1.0,
755
+ alpha: float = 1.0,
756
+ zorder: int = 1,
757
+ ) -> Any:
758
+ """Add a vertical line across the figure."""
759
+ line_dash = self._DASH_MAP.get(linestyle, "dashed")
760
+
761
+ span = Span(
762
+ location=x,
763
+ dimension="height",
764
+ line_color=color,
765
+ line_dash=line_dash,
766
+ line_width=linewidth,
767
+ line_alpha=alpha,
768
+ )
769
+ ax.add_layout(span)
770
+ return span
771
+
772
+ def hbar(
773
+ self,
774
+ ax: figure,
775
+ y: pd.Series,
776
+ width: pd.Series,
777
+ height: float = 0.8,
778
+ left: Union[float, pd.Series] = 0,
779
+ color: Union[str, List[str]] = "blue",
780
+ edgecolor: str = "black",
781
+ linewidth: float = 0.5,
782
+ zorder: int = 2,
783
+ ) -> Any:
784
+ """Create horizontal bar chart."""
785
+ # Convert left to array if scalar
786
+ if isinstance(left, (int, float)):
787
+ left_arr = [left] * len(y)
788
+ else:
789
+ left_arr = list(left) if hasattr(left, "tolist") else left
790
+
791
+ # Calculate right edge
792
+ right_arr = [left_val + w for left_val, w in zip(left_arr, width)]
793
+
794
+ return ax.hbar(
795
+ y=y.values,
796
+ right=right_arr,
797
+ left=left_arr,
798
+ height=height,
799
+ fill_color=color,
800
+ line_color=edgecolor,
801
+ line_width=linewidth,
802
+ )
803
+
804
+ def errorbar_h(
805
+ self,
806
+ ax: figure,
807
+ x: pd.Series,
808
+ y: pd.Series,
809
+ xerr_lower: pd.Series,
810
+ xerr_upper: pd.Series,
811
+ color: str = "black",
812
+ linewidth: float = 1.5,
813
+ capsize: float = 3,
814
+ zorder: int = 3,
815
+ ) -> Any:
816
+ """Add horizontal error bars."""
817
+ from bokeh.models import Whisker
818
+
819
+ # Calculate bounds
820
+ lower = x - xerr_lower
821
+ upper = x + xerr_upper
822
+
823
+ source = ColumnDataSource(
824
+ data={
825
+ "y": y.values,
826
+ "lower": lower.values,
827
+ "upper": upper.values,
828
+ }
829
+ )
830
+
831
+ # Add horizontal whisker
832
+ whisker = Whisker(
833
+ source=source,
834
+ base="y",
835
+ lower="lower",
836
+ upper="upper",
837
+ dimension="width",
838
+ line_color=color,
839
+ line_width=linewidth,
840
+ )
841
+ ax.add_layout(whisker)
842
+ return whisker
843
+
686
844
  def finalize_layout(
687
845
  self,
688
846
  fig: Any,
@@ -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