pylocuszoom 0.2.0__py3-none-any.whl → 0.3.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.
@@ -132,6 +132,7 @@ class PlotBackend(Protocol):
132
132
  color: str = "grey",
133
133
  linestyle: str = "--",
134
134
  linewidth: float = 1.0,
135
+ alpha: float = 1.0,
135
136
  zorder: int = 1,
136
137
  ) -> Any:
137
138
  """Add a horizontal line across the axes.
@@ -142,6 +143,7 @@ class PlotBackend(Protocol):
142
143
  color: Line color.
143
144
  linestyle: Line style.
144
145
  linewidth: Line width.
146
+ alpha: Line transparency (0-1).
145
147
  zorder: Drawing order.
146
148
 
147
149
  Returns:
@@ -8,7 +8,7 @@ from typing import Any, List, Optional, Tuple, Union
8
8
  import pandas as pd
9
9
  from bokeh.io import export_png, export_svgs, output_file, save, show
10
10
  from bokeh.layouts import column
11
- from bokeh.models import ColumnDataSource, HoverTool, Span
11
+ from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
12
12
  from bokeh.plotting import figure
13
13
 
14
14
 
@@ -56,29 +56,25 @@ class BokehBackend:
56
56
  heights = [int(total_height * r / total_ratio) for r in height_ratios]
57
57
 
58
58
  figures = []
59
- x_range = None
59
+ x_range = DataRange1d() if sharex else None
60
60
 
61
61
  for i, h in enumerate(heights):
62
62
  p = figure(
63
63
  width=width_px,
64
64
  height=h,
65
- x_range=x_range if sharex and x_range else None,
65
+ x_range=x_range if sharex else DataRange1d(),
66
66
  tools="pan,wheel_zoom,box_zoom,reset,save",
67
67
  toolbar_location="above" if i == 0 else None,
68
68
  )
69
69
 
70
- # Share x_range for subsequent figures
71
- if sharex and x_range is None:
72
- x_range = p.x_range
73
-
74
70
  # Style
75
71
  p.grid.grid_line_alpha = 0.3
76
72
  p.outline_line_color = None
77
73
 
78
74
  figures.append(p)
79
75
 
80
- # Create column layout
81
- layout = column(*figures, sizing_mode="fixed")
76
+ # Create column layout (use default sizing mode to avoid validation warnings)
77
+ layout = column(*figures)
82
78
 
83
79
  return layout, figures
84
80
 
@@ -118,29 +114,34 @@ class BokehBackend:
118
114
  if hover_data is not None:
119
115
  for col in hover_data.columns:
120
116
  data[col] = hover_data[col].values
121
- if "p" in col.lower():
117
+ col_lower = col.lower()
118
+ if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
122
119
  tooltips.append((col, "@{" + col + "}{0.2e}"))
123
- elif "r2" in col.lower() or "ld" in col.lower():
120
+ elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
124
121
  tooltips.append((col, "@{" + col + "}{0.3f}"))
122
+ elif "pos" in col_lower:
123
+ tooltips.append((col, "@{" + col + "}{0,0}"))
125
124
  else:
126
125
  tooltips.append((col, f"@{col}"))
127
126
 
128
127
  source = ColumnDataSource(data)
129
128
 
130
- # Get marker type
129
+ # Get marker type for scatter()
131
130
  marker_type = self._marker_map.get(marker, "circle")
132
131
 
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
- )
132
+ # Create scatter using scatter() method (Bokeh 3.4+ preferred API)
133
+ scatter_kwargs = {
134
+ "source": source,
135
+ "marker": marker_type,
136
+ "size": "size",
137
+ "fill_color": "color",
138
+ "line_color": edgecolor,
139
+ "line_width": linewidth,
140
+ }
141
+ if label:
142
+ scatter_kwargs["legend_label"] = label
143
+
144
+ renderer = ax.scatter("x", "y", **scatter_kwargs)
144
145
 
145
146
  # Add hover tool if we have hover data
146
147
  if tooltips:
@@ -175,15 +176,16 @@ class BokehBackend:
175
176
  }
176
177
  line_dash = dash_map.get(linestyle, "solid")
177
178
 
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
- )
179
+ line_kwargs = {
180
+ "line_color": color,
181
+ "line_width": linewidth,
182
+ "line_alpha": alpha,
183
+ "line_dash": line_dash,
184
+ }
185
+ if label:
186
+ line_kwargs["legend_label"] = label
187
+
188
+ return ax.line(x.values, y.values, **line_kwargs)
187
189
 
188
190
  def fill_between(
189
191
  self,
@@ -223,6 +225,7 @@ class BokehBackend:
223
225
  color: str = "grey",
224
226
  linestyle: str = "--",
225
227
  linewidth: float = 1.0,
228
+ alpha: float = 1.0,
226
229
  zorder: int = 1,
227
230
  ) -> Any:
228
231
  """Add a horizontal line across the figure."""
@@ -235,6 +238,7 @@ class BokehBackend:
235
238
  line_color=color,
236
239
  line_dash=line_dash,
237
240
  line_width=linewidth,
241
+ line_alpha=alpha,
238
242
  )
239
243
  ax.add_layout(span)
240
244
  return span
@@ -303,6 +307,28 @@ class BokehBackend:
303
307
  line_width=linewidth,
304
308
  )
305
309
 
310
+ def add_polygon(
311
+ self,
312
+ ax: figure,
313
+ points: List[List[float]],
314
+ facecolor: str = "blue",
315
+ edgecolor: str = "black",
316
+ linewidth: float = 0.5,
317
+ zorder: int = 2,
318
+ ) -> Any:
319
+ """Add a polygon (e.g., triangle for strand arrows) to the figure."""
320
+ xs = [p[0] for p in points]
321
+ ys = [p[1] for p in points]
322
+
323
+ # Bokeh patch() uses x/y (singular) for single polygon
324
+ return ax.patch(
325
+ x=xs,
326
+ y=ys,
327
+ fill_color=facecolor,
328
+ line_color=edgecolor,
329
+ line_width=linewidth,
330
+ )
331
+
306
332
  def set_xlim(self, ax: figure, left: float, right: float) -> None:
307
333
  """Set x-axis limits."""
308
334
  ax.x_range.start = left
@@ -315,14 +341,31 @@ class BokehBackend:
315
341
 
316
342
  def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
317
343
  """Set x-axis label."""
344
+ label = self._convert_label(label)
318
345
  ax.xaxis.axis_label = label
319
346
  ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
320
347
 
321
348
  def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
322
349
  """Set y-axis label."""
350
+ label = self._convert_label(label)
323
351
  ax.yaxis.axis_label = label
324
352
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
325
353
 
354
+ def _convert_label(self, label: str) -> str:
355
+ """Convert LaTeX-style labels to Unicode for Bokeh display."""
356
+ conversions = [
357
+ (r"$-\log_{10}$ P", "-log₁₀(P)"),
358
+ (r"$-\log_{10}$", "-log₁₀"),
359
+ (r"\log_{10}", "log₁₀"),
360
+ (r"$r^2$", "r²"),
361
+ (r"$R^2$", "R²"),
362
+ ]
363
+ for latex, unicode_str in conversions:
364
+ if latex in label:
365
+ label = label.replace(latex, unicode_str)
366
+ label = label.replace("$", "")
367
+ return label
368
+
326
369
  def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
327
370
  """Set figure title."""
328
371
  ax.title.text = title
@@ -341,6 +384,141 @@ class BokehBackend:
341
384
 
342
385
  return "secondary"
343
386
 
387
+ def line_secondary(
388
+ self,
389
+ ax: figure,
390
+ x: pd.Series,
391
+ y: pd.Series,
392
+ color: str = "blue",
393
+ linewidth: float = 1.5,
394
+ alpha: float = 1.0,
395
+ linestyle: str = "-",
396
+ label: Optional[str] = None,
397
+ yaxis_name: str = "secondary",
398
+ ) -> Any:
399
+ """Create a line plot on secondary y-axis."""
400
+ dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
401
+ line_dash = dash_map.get(linestyle, "solid")
402
+
403
+ return ax.line(
404
+ x.values,
405
+ y.values,
406
+ line_color=color,
407
+ line_width=linewidth,
408
+ line_alpha=alpha,
409
+ line_dash=line_dash,
410
+ y_range_name=yaxis_name,
411
+ )
412
+
413
+ def fill_between_secondary(
414
+ self,
415
+ ax: figure,
416
+ x: pd.Series,
417
+ y1: Union[float, pd.Series],
418
+ y2: Union[float, pd.Series],
419
+ color: str = "blue",
420
+ alpha: float = 0.3,
421
+ yaxis_name: str = "secondary",
422
+ ) -> Any:
423
+ """Fill area between two y-values on secondary y-axis."""
424
+ x_arr = x.values
425
+ if isinstance(y1, (int, float)):
426
+ y1_arr = [y1] * len(x_arr)
427
+ else:
428
+ y1_arr = y1.values if hasattr(y1, "values") else list(y1)
429
+
430
+ if isinstance(y2, (int, float)):
431
+ y2_arr = [y2] * len(x_arr)
432
+ else:
433
+ y2_arr = y2.values if hasattr(y2, "values") else list(y2)
434
+
435
+ return ax.varea(
436
+ x=x_arr,
437
+ y1=y1_arr,
438
+ y2=y2_arr,
439
+ fill_color=color,
440
+ fill_alpha=alpha,
441
+ y_range_name=yaxis_name,
442
+ )
443
+
444
+ def set_secondary_ylim(
445
+ self,
446
+ ax: figure,
447
+ bottom: float,
448
+ top: float,
449
+ yaxis_name: str = "secondary",
450
+ ) -> None:
451
+ """Set secondary y-axis limits."""
452
+ if yaxis_name in ax.extra_y_ranges:
453
+ ax.extra_y_ranges[yaxis_name].start = bottom
454
+ ax.extra_y_ranges[yaxis_name].end = top
455
+
456
+ def set_secondary_ylabel(
457
+ self,
458
+ ax: figure,
459
+ label: str,
460
+ color: str = "black",
461
+ fontsize: int = 10,
462
+ yaxis_name: str = "secondary",
463
+ ) -> None:
464
+ """Set secondary y-axis label."""
465
+ label = self._convert_label(label)
466
+ # Find the secondary axis and update its label
467
+ for renderer in ax.right:
468
+ if hasattr(renderer, "y_range_name") and renderer.y_range_name == yaxis_name:
469
+ renderer.axis_label = label
470
+ renderer.axis_label_text_font_size = f"{fontsize}pt"
471
+ renderer.axis_label_text_color = color
472
+ renderer.major_label_text_color = color
473
+ break
474
+
475
+ def add_ld_legend(
476
+ self,
477
+ ax: figure,
478
+ ld_bins: List[Tuple[float, str, str]],
479
+ lead_snp_color: str,
480
+ ) -> None:
481
+ """Add LD color legend using invisible dummy glyphs.
482
+
483
+ Creates legend entries with dummy renderers that are excluded from
484
+ the data range calculation to avoid affecting axis scaling.
485
+ """
486
+ from bokeh.models import ColumnDataSource, Legend, LegendItem, Range1d, Scatter
487
+
488
+ legend_items = []
489
+
490
+ # Create a separate range for legend glyphs that won't affect the main plot
491
+ if "legend_range" not in ax.extra_y_ranges:
492
+ ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
493
+
494
+ # Use coordinates within the legend range
495
+ dummy_source = ColumnDataSource(data={"x": [0], "y": [0]})
496
+
497
+ # Add LD bin markers (no lead SNP - it's shown in the actual plot)
498
+ for _, label, color in ld_bins:
499
+ glyph = Scatter(
500
+ x="x",
501
+ y="y",
502
+ marker="square",
503
+ size=10,
504
+ fill_color=color,
505
+ line_color="black",
506
+ line_width=0.5,
507
+ )
508
+ renderer = ax.add_glyph(dummy_source, glyph)
509
+ renderer.y_range_name = "legend_range"
510
+ renderer.visible = False
511
+ legend_items.append(LegendItem(label=label, renderers=[renderer]))
512
+
513
+ legend = Legend(
514
+ items=legend_items,
515
+ location="top_right",
516
+ title="r²",
517
+ background_fill_alpha=0.9,
518
+ border_line_color="black",
519
+ )
520
+ ax.add_layout(legend)
521
+
344
522
  def add_legend(
345
523
  self,
346
524
  ax: figure,
@@ -369,26 +547,20 @@ class BokehBackend:
369
547
  return ax.legend
370
548
 
371
549
  def hide_spines(self, ax: figure, spines: List[str]) -> None:
372
- """Hide specified axis spines."""
373
- # Bokeh doesn't have spines in the same way
374
- # We can hide axis lines
375
- if "top" in spines:
376
- ax.xaxis.visible = ax.xaxis.visible # Keep visible but could customize
377
- if "right" in spines:
378
- ax.yaxis.visible = ax.yaxis.visible
550
+ """Hide specified axis spines (no-op for Bokeh).
551
+
552
+ Bokeh doesn't have matplotlib-style spines. This method exists
553
+ for interface compatibility but has no visual effect.
554
+ """
555
+ pass
379
556
 
380
557
  def format_xaxis_mb(self, ax: figure) -> None:
381
558
  """Format x-axis to show megabase values."""
382
- from bokeh.models import NumeralTickFormatter
559
+ from bokeh.models import CustomJSTickFormatter
383
560
 
384
- ax.xaxis.formatter = NumeralTickFormatter(format="0.00")
385
- ax.xaxis.axis_label = ax.xaxis.axis_label or "Position (Mb)"
386
-
387
- # We need to scale values or use a custom formatter
388
- # For now, assume values are already in bp and need /1e6
389
- from bokeh.models import FuncTickFormatter
390
-
391
- ax.xaxis.formatter = FuncTickFormatter(code="return (tick / 1e6).toFixed(2);")
561
+ ax.xaxis.formatter = CustomJSTickFormatter(
562
+ code="return (tick / 1e6).toFixed(2);"
563
+ )
392
564
 
393
565
  def save(
394
566
  self,
@@ -9,7 +9,7 @@ import matplotlib.pyplot as plt
9
9
  import pandas as pd
10
10
  from matplotlib.axes import Axes
11
11
  from matplotlib.figure import Figure
12
- from matplotlib.patches import Rectangle
12
+ from matplotlib.patches import Polygon, Rectangle
13
13
  from matplotlib.ticker import FuncFormatter, MaxNLocator
14
14
 
15
15
 
@@ -55,13 +55,8 @@ class MatplotlibBackend:
55
55
  figsize=figsize,
56
56
  height_ratios=height_ratios,
57
57
  sharex=sharex,
58
- gridspec_kw={"hspace": 0},
59
58
  )
60
59
 
61
- # Ensure axes is always a list
62
- if n_panels == 1:
63
- axes = [axes]
64
-
65
60
  return fig, list(axes)
66
61
 
67
62
  def scatter(
@@ -139,11 +134,17 @@ class MatplotlibBackend:
139
134
  color: str = "grey",
140
135
  linestyle: str = "--",
141
136
  linewidth: float = 1.0,
137
+ alpha: float = 1.0,
142
138
  zorder: int = 1,
143
139
  ) -> Any:
144
140
  """Add a horizontal line across the axes."""
145
141
  return ax.axhline(
146
- y=y, color=color, linestyle=linestyle, linewidth=linewidth, zorder=zorder
142
+ y=y,
143
+ color=color,
144
+ linestyle=linestyle,
145
+ linewidth=linewidth,
146
+ alpha=alpha,
147
+ zorder=zorder,
147
148
  )
148
149
 
149
150
  def add_text(
@@ -187,6 +188,27 @@ class MatplotlibBackend:
187
188
  ax.add_patch(rect)
188
189
  return rect
189
190
 
191
+ def add_polygon(
192
+ self,
193
+ ax: Axes,
194
+ points: List[List[float]],
195
+ facecolor: str = "blue",
196
+ edgecolor: str = "black",
197
+ linewidth: float = 0.5,
198
+ zorder: int = 2,
199
+ ) -> Any:
200
+ """Add a polygon patch to axes."""
201
+ polygon = Polygon(
202
+ points,
203
+ closed=True,
204
+ facecolor=facecolor,
205
+ edgecolor=edgecolor,
206
+ linewidth=linewidth,
207
+ zorder=zorder,
208
+ )
209
+ ax.add_patch(polygon)
210
+ return polygon
211
+
190
212
  def set_xlim(self, ax: Axes, left: float, right: float) -> None:
191
213
  """Set x-axis limits."""
192
214
  ax.set_xlim(left, right)