pylocuszoom 0.1.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,441 @@
1
+ """Bokeh backend for pyLocusZoom.
2
+
3
+ Interactive backend with hover tooltips, well-suited for dashboards.
4
+ """
5
+
6
+ from typing import Any, List, Optional, Tuple, Union
7
+
8
+ import pandas as pd
9
+ from bokeh.io import export_png, export_svgs, output_file, save, show
10
+ from bokeh.layouts import column
11
+ from bokeh.models import ColumnDataSource, HoverTool, Legend, LegendItem, Span
12
+ from bokeh.plotting import figure
13
+
14
+
15
+ class BokehBackend:
16
+ """Bokeh backend for interactive plot generation.
17
+
18
+ Produces interactive HTML plots suitable for embedding in web
19
+ applications and dashboards.
20
+ """
21
+
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
+ }
31
+
32
+ def create_figure(
33
+ self,
34
+ n_panels: int,
35
+ height_ratios: List[float],
36
+ figsize: Tuple[float, float],
37
+ sharex: bool = True,
38
+ ) -> Tuple[Any, List[figure]]:
39
+ """Create a layout with multiple panels.
40
+
41
+ Args:
42
+ n_panels: Number of vertical panels.
43
+ height_ratios: Relative heights for each panel.
44
+ figsize: Figure size as (width, height) in inches.
45
+ sharex: Whether panels share the x-axis.
46
+
47
+ Returns:
48
+ Tuple of (layout, list of figure objects).
49
+ """
50
+ # Convert inches to pixels
51
+ width_px = int(figsize[0] * 100)
52
+ total_height = int(figsize[1] * 100)
53
+
54
+ # Calculate individual heights
55
+ total_ratio = sum(height_ratios)
56
+ heights = [int(total_height * r / total_ratio) for r in height_ratios]
57
+
58
+ figures = []
59
+ x_range = None
60
+
61
+ for i, h in enumerate(heights):
62
+ p = figure(
63
+ width=width_px,
64
+ height=h,
65
+ x_range=x_range if sharex and x_range else None,
66
+ tools="pan,wheel_zoom,box_zoom,reset,save",
67
+ toolbar_location="above" if i == 0 else None,
68
+ )
69
+
70
+ # Share x_range for subsequent figures
71
+ if sharex and x_range is None:
72
+ x_range = p.x_range
73
+
74
+ # Style
75
+ p.grid.grid_line_alpha = 0.3
76
+ p.outline_line_color = None
77
+
78
+ figures.append(p)
79
+
80
+ # Create column layout
81
+ layout = column(*figures, sizing_mode="fixed")
82
+
83
+ return layout, figures
84
+
85
+ def scatter(
86
+ self,
87
+ ax: figure,
88
+ x: pd.Series,
89
+ y: pd.Series,
90
+ colors: Union[str, List[str], pd.Series],
91
+ sizes: Union[float, List[float], pd.Series] = 60,
92
+ marker: str = "o",
93
+ edgecolor: str = "black",
94
+ linewidth: float = 0.5,
95
+ zorder: int = 2,
96
+ hover_data: Optional[pd.DataFrame] = None,
97
+ label: Optional[str] = None,
98
+ ) -> Any:
99
+ """Create a scatter plot on the given figure."""
100
+ # Prepare data source
101
+ data = {"x": x.values, "y": y.values}
102
+
103
+ # Handle colors
104
+ if isinstance(colors, str):
105
+ data["color"] = [colors] * len(x)
106
+ else:
107
+ data["color"] = list(colors) if hasattr(colors, "tolist") else colors
108
+
109
+ # Handle sizes (convert from area to diameter)
110
+ if isinstance(sizes, (int, float)):
111
+ bokeh_size = max(6, sizes ** 0.5)
112
+ data["size"] = [bokeh_size] * len(x)
113
+ else:
114
+ data["size"] = [max(6, s ** 0.5) for s in sizes]
115
+
116
+ # Add hover data
117
+ tooltips = []
118
+ if hover_data is not None:
119
+ for col in hover_data.columns:
120
+ data[col] = hover_data[col].values
121
+ if "p" in col.lower():
122
+ tooltips.append((col, "@{" + col + "}{0.2e}"))
123
+ elif "r2" in col.lower() or "ld" in col.lower():
124
+ tooltips.append((col, "@{" + col + "}{0.3f}"))
125
+ else:
126
+ tooltips.append((col, f"@{col}"))
127
+
128
+ source = ColumnDataSource(data)
129
+
130
+ # Get marker type
131
+ marker_type = self._marker_map.get(marker, "circle")
132
+
133
+ # Create scatter
134
+ renderer = getattr(ax, marker_type)(
135
+ "x",
136
+ "y",
137
+ source=source,
138
+ size="size",
139
+ fill_color="color",
140
+ line_color=edgecolor,
141
+ line_width=linewidth,
142
+ legend_label=label if label else None,
143
+ )
144
+
145
+ # Add hover tool if we have hover data
146
+ if tooltips:
147
+ hover = HoverTool(
148
+ tooltips=tooltips,
149
+ renderers=[renderer],
150
+ mode="mouse",
151
+ )
152
+ ax.add_tools(hover)
153
+
154
+ return renderer
155
+
156
+ def line(
157
+ self,
158
+ ax: figure,
159
+ x: pd.Series,
160
+ y: pd.Series,
161
+ color: str = "blue",
162
+ linewidth: float = 1.5,
163
+ alpha: float = 1.0,
164
+ linestyle: str = "-",
165
+ zorder: int = 1,
166
+ label: Optional[str] = None,
167
+ ) -> Any:
168
+ """Create a line plot on the given figure."""
169
+ # Convert linestyle
170
+ dash_map = {
171
+ "-": "solid",
172
+ "--": "dashed",
173
+ ":": "dotted",
174
+ "-.": "dashdot",
175
+ }
176
+ line_dash = dash_map.get(linestyle, "solid")
177
+
178
+ return ax.line(
179
+ x.values,
180
+ y.values,
181
+ line_color=color,
182
+ line_width=linewidth,
183
+ line_alpha=alpha,
184
+ line_dash=line_dash,
185
+ legend_label=label if label else None,
186
+ )
187
+
188
+ def fill_between(
189
+ self,
190
+ ax: figure,
191
+ x: pd.Series,
192
+ y1: Union[float, pd.Series],
193
+ y2: Union[float, pd.Series],
194
+ color: str = "blue",
195
+ alpha: float = 0.3,
196
+ zorder: int = 0,
197
+ ) -> Any:
198
+ """Fill area between two y-values."""
199
+ # Convert to arrays
200
+ x_arr = x.values
201
+ if isinstance(y1, (int, float)):
202
+ y1_arr = [y1] * len(x_arr)
203
+ else:
204
+ y1_arr = y1.values if hasattr(y1, "values") else list(y1)
205
+
206
+ if isinstance(y2, (int, float)):
207
+ y2_arr = [y2] * len(x_arr)
208
+ else:
209
+ y2_arr = y2.values if hasattr(y2, "values") else list(y2)
210
+
211
+ return ax.varea(
212
+ x=x_arr,
213
+ y1=y1_arr,
214
+ y2=y2_arr,
215
+ fill_color=color,
216
+ fill_alpha=alpha,
217
+ )
218
+
219
+ def axhline(
220
+ self,
221
+ ax: figure,
222
+ y: float,
223
+ color: str = "grey",
224
+ linestyle: str = "--",
225
+ linewidth: float = 1.0,
226
+ zorder: int = 1,
227
+ ) -> Any:
228
+ """Add a horizontal line across the figure."""
229
+ dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
230
+ line_dash = dash_map.get(linestyle, "dashed")
231
+
232
+ span = Span(
233
+ location=y,
234
+ dimension="width",
235
+ line_color=color,
236
+ line_dash=line_dash,
237
+ line_width=linewidth,
238
+ )
239
+ ax.add_layout(span)
240
+ return span
241
+
242
+ def add_text(
243
+ self,
244
+ ax: figure,
245
+ x: float,
246
+ y: float,
247
+ text: str,
248
+ fontsize: int = 10,
249
+ ha: str = "center",
250
+ va: str = "bottom",
251
+ rotation: float = 0,
252
+ color: str = "black",
253
+ ) -> Any:
254
+ """Add text annotation to figure."""
255
+ from bokeh.models import Label
256
+
257
+ # Map alignment
258
+ anchor_map = {
259
+ ("center", "bottom"): ("center", "bottom"),
260
+ ("center", "top"): ("center", "top"),
261
+ ("left", "bottom"): ("left", "bottom"),
262
+ ("right", "bottom"): ("right", "bottom"),
263
+ }
264
+ text_align, text_baseline = anchor_map.get((ha, va), ("center", "bottom"))
265
+
266
+ label = Label(
267
+ x=x,
268
+ y=y,
269
+ text=text,
270
+ text_font_size=f"{fontsize}pt",
271
+ text_color=color,
272
+ text_align=text_align,
273
+ text_baseline=text_baseline,
274
+ angle=rotation,
275
+ angle_units="deg",
276
+ )
277
+ ax.add_layout(label)
278
+ return label
279
+
280
+ def add_rectangle(
281
+ self,
282
+ ax: figure,
283
+ xy: Tuple[float, float],
284
+ width: float,
285
+ height: float,
286
+ facecolor: str = "blue",
287
+ edgecolor: str = "black",
288
+ linewidth: float = 0.5,
289
+ zorder: int = 2,
290
+ ) -> Any:
291
+ """Add a rectangle to the figure."""
292
+ from bokeh.models import Rect
293
+
294
+ x_center = xy[0] + width / 2
295
+ y_center = xy[1] + height / 2
296
+
297
+ return ax.rect(
298
+ x=[x_center],
299
+ y=[y_center],
300
+ width=[width],
301
+ height=[height],
302
+ fill_color=facecolor,
303
+ line_color=edgecolor,
304
+ line_width=linewidth,
305
+ )
306
+
307
+ def set_xlim(self, ax: figure, left: float, right: float) -> None:
308
+ """Set x-axis limits."""
309
+ ax.x_range.start = left
310
+ ax.x_range.end = right
311
+
312
+ def set_ylim(self, ax: figure, bottom: float, top: float) -> None:
313
+ """Set y-axis limits."""
314
+ ax.y_range.start = bottom
315
+ ax.y_range.end = top
316
+
317
+ def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
318
+ """Set x-axis label."""
319
+ ax.xaxis.axis_label = label
320
+ ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
321
+
322
+ def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
323
+ """Set y-axis label."""
324
+ ax.yaxis.axis_label = label
325
+ ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
326
+
327
+ def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
328
+ """Set figure title."""
329
+ ax.title.text = title
330
+ ax.title.text_font_size = f"{fontsize}pt"
331
+
332
+ def create_twin_axis(self, ax: figure) -> Any:
333
+ """Create a secondary y-axis.
334
+
335
+ Returns a dict with configuration for extra_y_ranges.
336
+ """
337
+ from bokeh.models import LinearAxis, Range1d
338
+
339
+ # Add a second y-axis
340
+ ax.extra_y_ranges = {"secondary": Range1d(start=0, end=100)}
341
+ ax.add_layout(LinearAxis(y_range_name="secondary"), "right")
342
+
343
+ return "secondary"
344
+
345
+ def add_legend(
346
+ self,
347
+ ax: figure,
348
+ handles: List[Any],
349
+ labels: List[str],
350
+ loc: str = "upper left",
351
+ title: Optional[str] = None,
352
+ ) -> Any:
353
+ """Configure legend on the figure."""
354
+ # Bokeh handles legend automatically from legend_label
355
+ # Just configure position
356
+
357
+ loc_map = {
358
+ "upper left": "top_left",
359
+ "upper right": "top_right",
360
+ "lower left": "bottom_left",
361
+ "lower right": "bottom_right",
362
+ }
363
+
364
+ ax.legend.location = loc_map.get(loc, "top_left")
365
+ if title:
366
+ ax.legend.title = title
367
+ ax.legend.background_fill_alpha = 0.9
368
+ ax.legend.border_line_color = "black"
369
+
370
+ return ax.legend
371
+
372
+ def hide_spines(self, ax: figure, spines: List[str]) -> None:
373
+ """Hide specified axis spines."""
374
+ # Bokeh doesn't have spines in the same way
375
+ # We can hide axis lines
376
+ if "top" in spines:
377
+ ax.xaxis.visible = ax.xaxis.visible # Keep visible but could customize
378
+ if "right" in spines:
379
+ ax.yaxis.visible = ax.yaxis.visible
380
+
381
+ def format_xaxis_mb(self, ax: figure) -> None:
382
+ """Format x-axis to show megabase values."""
383
+ from bokeh.models import NumeralTickFormatter
384
+
385
+ ax.xaxis.formatter = NumeralTickFormatter(format="0.00")
386
+ ax.xaxis.axis_label = ax.xaxis.axis_label or "Position (Mb)"
387
+
388
+ # We need to scale values or use a custom formatter
389
+ # For now, assume values are already in bp and need /1e6
390
+ from bokeh.models import FuncTickFormatter
391
+
392
+ ax.xaxis.formatter = FuncTickFormatter(
393
+ code="return (tick / 1e6).toFixed(2);"
394
+ )
395
+
396
+ def save(
397
+ self,
398
+ fig: Any,
399
+ path: str,
400
+ dpi: int = 150,
401
+ bbox_inches: str = "tight",
402
+ ) -> None:
403
+ """Save figure to file.
404
+
405
+ Supports .html for interactive and .png for static.
406
+ """
407
+ if path.endswith(".html"):
408
+ output_file(path)
409
+ save(fig)
410
+ elif path.endswith(".png"):
411
+ export_png(fig, filename=path)
412
+ elif path.endswith(".svg"):
413
+ export_svgs(fig, filename=path)
414
+ else:
415
+ # Default to HTML
416
+ output_file(path)
417
+ save(fig)
418
+
419
+ def show(self, fig: Any) -> None:
420
+ """Display the figure."""
421
+ show(fig)
422
+
423
+ def close(self, fig: Any) -> None:
424
+ """Close the figure (no-op for bokeh)."""
425
+ pass
426
+
427
+ def finalize_layout(
428
+ self,
429
+ fig: Any,
430
+ left: float = 0.08,
431
+ right: float = 0.95,
432
+ top: float = 0.95,
433
+ bottom: float = 0.1,
434
+ hspace: float = 0.08,
435
+ ) -> None:
436
+ """Adjust layout (limited support in bokeh).
437
+
438
+ Bokeh layouts are mostly automatic.
439
+ """
440
+ # Bokeh handles layout differently - column spacing is fixed
441
+ pass