pylocuszoom 0.1.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.
@@ -71,8 +71,9 @@ class PlotlyBackend:
71
71
  template="plotly_white",
72
72
  )
73
73
 
74
- # Return row indices (1-based for plotly)
75
- panel_refs = list(range(1, n_panels + 1))
74
+ # Return (fig, row) tuples for each panel
75
+ # This matches the expected ax parameter format for all methods
76
+ panel_refs = [(fig, row) for row in range(1, n_panels + 1)]
76
77
  return fig, panel_refs
77
78
 
78
79
  def scatter(
@@ -100,9 +101,9 @@ class PlotlyBackend:
100
101
 
101
102
  # Convert size (matplotlib uses area, plotly uses diameter)
102
103
  if isinstance(sizes, (int, float)):
103
- size = max(6, sizes ** 0.5) # Approximate conversion
104
+ size = max(6, sizes**0.5) # Approximate conversion
104
105
  else:
105
- size = [max(6, s ** 0.5) for s in sizes]
106
+ size = [max(6, s**0.5) for s in sizes]
106
107
 
107
108
  # Build hover template
108
109
  if hover_data is not None:
@@ -110,10 +111,13 @@ class PlotlyBackend:
110
111
  hover_cols = hover_data.columns.tolist()
111
112
  hovertemplate = "<b>%{customdata[0]}</b><br>"
112
113
  for i, col in enumerate(hover_cols[1:], 1):
113
- if "p" in col.lower():
114
+ col_lower = col.lower()
115
+ if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
114
116
  hovertemplate += f"{col}: %{{customdata[{i}]:.2e}}<br>"
115
- elif "r2" in col.lower() or "ld" in col.lower():
117
+ elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
116
118
  hovertemplate += f"{col}: %{{customdata[{i}]:.3f}}<br>"
119
+ elif "pos" in col_lower:
120
+ hovertemplate += f"{col}: %{{customdata[{i}]:,.0f}}<br>"
117
121
  else:
118
122
  hovertemplate += f"{col}: %{{customdata[{i}]}}<br>"
119
123
  hovertemplate += "<extra></extra>"
@@ -221,6 +225,7 @@ class PlotlyBackend:
221
225
  color: str = "grey",
222
226
  linestyle: str = "--",
223
227
  linewidth: float = 1.0,
228
+ alpha: float = 1.0,
224
229
  zorder: int = 1,
225
230
  ) -> Any:
226
231
  """Add a horizontal line across the panel."""
@@ -234,6 +239,7 @@ class PlotlyBackend:
234
239
  line_dash=dash,
235
240
  line_color=color,
236
241
  line_width=linewidth,
242
+ opacity=alpha,
237
243
  row=row,
238
244
  col=1,
239
245
  )
@@ -299,6 +305,33 @@ class PlotlyBackend:
299
305
  col=1,
300
306
  )
301
307
 
308
+ def add_polygon(
309
+ self,
310
+ ax: Tuple[go.Figure, int],
311
+ points: List[List[float]],
312
+ facecolor: str = "blue",
313
+ edgecolor: str = "black",
314
+ linewidth: float = 0.5,
315
+ zorder: int = 2,
316
+ ) -> Any:
317
+ """Add a polygon (e.g., triangle for strand arrows) to the panel."""
318
+ fig, row = ax
319
+
320
+ # Build SVG path from points
321
+ path = f"M {points[0][0]} {points[0][1]}"
322
+ for px, py in points[1:]:
323
+ path += f" L {px} {py}"
324
+ path += " Z"
325
+
326
+ fig.add_shape(
327
+ type="path",
328
+ path=path,
329
+ fillcolor=facecolor,
330
+ line=dict(color=edgecolor, width=linewidth),
331
+ row=row,
332
+ col=1,
333
+ )
334
+
302
335
  def set_xlim(self, ax: Tuple[go.Figure, int], left: float, right: float) -> None:
303
336
  """Set x-axis limits."""
304
337
  fig, row = ax
@@ -317,7 +350,11 @@ class PlotlyBackend:
317
350
  """Set x-axis label."""
318
351
  fig, row = ax
319
352
  xaxis = f"xaxis{row}" if row > 1 else "xaxis"
320
- fig.update_layout(**{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))})
353
+ # Convert LaTeX-style labels to Unicode for Plotly
354
+ label = self._convert_label(label)
355
+ fig.update_layout(
356
+ **{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
357
+ )
321
358
 
322
359
  def set_ylabel(
323
360
  self, ax: Tuple[go.Figure, int], label: str, fontsize: int = 12
@@ -325,7 +362,28 @@ class PlotlyBackend:
325
362
  """Set y-axis label."""
326
363
  fig, row = ax
327
364
  yaxis = f"yaxis{row}" if row > 1 else "yaxis"
328
- fig.update_layout(**{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))})
365
+ # Convert LaTeX-style labels to Unicode for Plotly
366
+ label = self._convert_label(label)
367
+ fig.update_layout(
368
+ **{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
369
+ )
370
+
371
+ def _convert_label(self, label: str) -> str:
372
+ """Convert LaTeX-style labels to Unicode for Plotly display."""
373
+ # Common conversions for genomics plots
374
+ conversions = [
375
+ (r"$-\log_{10}$ P", "-log₁₀(P)"),
376
+ (r"$-\log_{10}$", "-log₁₀"),
377
+ (r"\log_{10}", "log₁₀"),
378
+ (r"$r^2$", "r²"),
379
+ (r"$R^2$", "R²"),
380
+ ]
381
+ for latex, unicode_str in conversions:
382
+ if latex in label:
383
+ label = label.replace(latex, unicode_str)
384
+ # Remove any remaining $ markers
385
+ label = label.replace("$", "")
386
+ return label
329
387
 
330
388
  def set_title(
331
389
  self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
@@ -356,6 +414,146 @@ class PlotlyBackend:
356
414
 
357
415
  return (fig, row, secondary_y)
358
416
 
417
+ def line_secondary(
418
+ self,
419
+ ax: Tuple[go.Figure, int],
420
+ x: pd.Series,
421
+ y: pd.Series,
422
+ color: str = "blue",
423
+ linewidth: float = 1.5,
424
+ alpha: float = 1.0,
425
+ linestyle: str = "-",
426
+ label: Optional[str] = None,
427
+ yaxis_name: str = "y2",
428
+ ) -> Any:
429
+ """Create a line plot on secondary y-axis."""
430
+ fig, row = ax
431
+
432
+ dash_map = {"-": "solid", "--": "dash", ":": "dot", "-.": "dashdot"}
433
+ dash = dash_map.get(linestyle, "solid")
434
+
435
+ trace = go.Scatter(
436
+ x=x,
437
+ y=y,
438
+ mode="lines",
439
+ line=dict(color=color, width=linewidth, dash=dash),
440
+ opacity=alpha,
441
+ name=label or "",
442
+ showlegend=label is not None,
443
+ yaxis=yaxis_name,
444
+ hoverinfo="skip",
445
+ )
446
+
447
+ fig.add_trace(trace, row=row, col=1)
448
+ return trace
449
+
450
+ def fill_between_secondary(
451
+ self,
452
+ ax: Tuple[go.Figure, int],
453
+ x: pd.Series,
454
+ y1: Union[float, pd.Series],
455
+ y2: Union[float, pd.Series],
456
+ color: str = "blue",
457
+ alpha: float = 0.3,
458
+ yaxis_name: str = "y2",
459
+ ) -> Any:
460
+ """Fill area between two y-values on secondary y-axis."""
461
+ fig, row = ax
462
+
463
+ if isinstance(y1, (int, float)):
464
+ y1 = pd.Series([y1] * len(x))
465
+
466
+ trace = go.Scatter(
467
+ x=pd.concat([x, x[::-1]]),
468
+ y=pd.concat([y2, y1[::-1]]),
469
+ fill="toself",
470
+ fillcolor=color,
471
+ opacity=alpha,
472
+ line=dict(width=0),
473
+ showlegend=False,
474
+ hoverinfo="skip",
475
+ yaxis=yaxis_name,
476
+ )
477
+
478
+ fig.add_trace(trace, row=row, col=1)
479
+ return trace
480
+
481
+ def set_secondary_ylim(
482
+ self,
483
+ ax: Tuple[go.Figure, int],
484
+ bottom: float,
485
+ top: float,
486
+ yaxis_name: str = "y2",
487
+ ) -> None:
488
+ """Set secondary y-axis limits."""
489
+ fig, row = ax
490
+ yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
491
+ fig.update_layout(**{yaxis_key: dict(range=[bottom, top])})
492
+
493
+ def set_secondary_ylabel(
494
+ self,
495
+ ax: Tuple[go.Figure, int],
496
+ label: str,
497
+ color: str = "black",
498
+ fontsize: int = 10,
499
+ yaxis_name: str = "y2",
500
+ ) -> None:
501
+ """Set secondary y-axis label."""
502
+ fig, row = ax
503
+ label = self._convert_label(label)
504
+ yaxis_key = "yaxis" + yaxis_name[1:] if yaxis_name.startswith("y") else yaxis_name
505
+ fig.update_layout(
506
+ **{
507
+ yaxis_key: dict(
508
+ title=dict(text=label, font=dict(size=fontsize, color=color)),
509
+ tickfont=dict(color=color),
510
+ )
511
+ }
512
+ )
513
+
514
+ def add_ld_legend(
515
+ self,
516
+ ax: Tuple[go.Figure, int],
517
+ ld_bins: List[Tuple[float, str, str]],
518
+ lead_snp_color: str,
519
+ ) -> None:
520
+ """Add LD color legend using invisible scatter traces."""
521
+ fig, row = ax
522
+
523
+ # Add LD bin markers (no lead SNP - it's shown in the actual plot)
524
+ for _, label, color in ld_bins:
525
+ fig.add_trace(
526
+ go.Scatter(
527
+ x=[None],
528
+ y=[None],
529
+ mode="markers",
530
+ marker=dict(
531
+ symbol="square",
532
+ size=10,
533
+ color=color,
534
+ line=dict(color="black", width=0.5),
535
+ ),
536
+ name=label,
537
+ showlegend=True,
538
+ ),
539
+ row=row,
540
+ col=1,
541
+ )
542
+
543
+ # Position legend
544
+ fig.update_layout(
545
+ legend=dict(
546
+ x=0.99,
547
+ y=0.99,
548
+ xanchor="right",
549
+ yanchor="top",
550
+ title=dict(text="r²"),
551
+ bgcolor="rgba(255,255,255,0.9)",
552
+ bordercolor="black",
553
+ borderwidth=1,
554
+ )
555
+ )
556
+
359
557
  def add_legend(
360
558
  self,
361
559
  ax: Tuple[go.Figure, int],
@@ -395,32 +593,20 @@ class PlotlyBackend:
395
593
 
396
594
  Plotly doesn't have spines, but we can hide axis lines.
397
595
  """
398
- fig, row = ax
399
-
400
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
401
- yaxis = f"yaxis{row}" if row > 1 else "yaxis"
402
-
403
- if "top" in spines or "right" in spines:
404
- # Plotly's template "plotly_white" already hides these
405
- pass
596
+ # Plotly's template "plotly_white" already hides top/right lines
597
+ # No action needed - method exists for API compatibility
598
+ pass
406
599
 
407
600
  def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
408
- """Format x-axis to show megabase values."""
409
- fig, row = ax
410
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
411
-
412
- fig.update_layout(
413
- **{
414
- xaxis: dict(
415
- tickformat=".2f",
416
- ticksuffix=" Mb",
417
- tickvals=None, # Auto
418
- )
419
- }
420
- )
601
+ """Format x-axis to show megabase values.
421
602
 
422
- # Apply custom tick formatting via ticktext/tickvals if needed
423
- # For now, let plotly auto-format
603
+ Stores the row for later tick formatting in finalize_layout.
604
+ """
605
+ fig, row = ax
606
+ # Store that this axis needs Mb formatting
607
+ if not hasattr(fig, "_mb_format_rows"):
608
+ fig._mb_format_rows = []
609
+ fig._mb_format_rows.append(row)
424
610
 
425
611
  def save(
426
612
  self,
@@ -457,7 +643,7 @@ class PlotlyBackend:
457
643
  bottom: float = 0.1,
458
644
  hspace: float = 0.08,
459
645
  ) -> None:
460
- """Adjust layout margins.
646
+ """Adjust layout margins and apply Mb tick formatting.
461
647
 
462
648
  Args:
463
649
  fig: Figure object.
@@ -472,3 +658,58 @@ class PlotlyBackend:
472
658
  b=int(bottom * fig.layout.height) if fig.layout.height else 80,
473
659
  )
474
660
  )
661
+
662
+ # Apply Mb tick formatting to marked axes
663
+ if hasattr(fig, "_mb_format_rows"):
664
+ import numpy as np
665
+
666
+ for row in fig._mb_format_rows:
667
+ xaxis_name = f"xaxis{row}" if row > 1 else "xaxis"
668
+ xaxis = getattr(fig.layout, xaxis_name, None)
669
+
670
+ # Get x-range from the axis or compute from data
671
+ x_range = None
672
+ if xaxis and xaxis.range:
673
+ x_range = xaxis.range
674
+ else:
675
+ # Compute from trace data
676
+ x_vals = []
677
+ for trace in fig.data:
678
+ if hasattr(trace, "x") and trace.x is not None:
679
+ x_vals.extend(list(trace.x))
680
+ if x_vals:
681
+ x_range = [min(x_vals), max(x_vals)]
682
+
683
+ if x_range:
684
+ # Create nice tick values in Mb
685
+ x_min_mb = x_range[0] / 1e6
686
+ x_max_mb = x_range[1] / 1e6
687
+ span_mb = x_max_mb - x_min_mb
688
+
689
+ # Choose tick spacing based on range
690
+ if span_mb <= 0.5:
691
+ tick_step = 0.1
692
+ elif span_mb <= 2:
693
+ tick_step = 0.25
694
+ elif span_mb <= 5:
695
+ tick_step = 0.5
696
+ elif span_mb <= 20:
697
+ tick_step = 2
698
+ else:
699
+ tick_step = 5
700
+
701
+ # Generate ticks
702
+ first_tick = np.ceil(x_min_mb / tick_step) * tick_step
703
+ tickvals_mb = np.arange(first_tick, x_max_mb + tick_step / 2, tick_step)
704
+ tickvals_bp = [v * 1e6 for v in tickvals_mb]
705
+ ticktext = [f"{v:.2f}" for v in tickvals_mb]
706
+
707
+ fig.update_layout(
708
+ **{
709
+ xaxis_name: dict(
710
+ tickvals=tickvals_bp,
711
+ ticktext=ticktext,
712
+ ticksuffix=" Mb",
713
+ )
714
+ }
715
+ )
pylocuszoom/colors.py CHANGED
@@ -29,6 +29,101 @@ LD_NA_LABEL = "NA"
29
29
  # Lead SNP color (purple diamond)
30
30
  LEAD_SNP_COLOR = "#7D26CD" # purple3
31
31
 
32
+ # Fine-mapping/SuSiE credible set colors
33
+ # Colors for up to 10 credible sets, matching locuszoomr style
34
+ CREDIBLE_SET_COLORS: List[str] = [
35
+ "#FF7F00", # orange (CS1)
36
+ "#1F78B4", # blue (CS2)
37
+ "#33A02C", # green (CS3)
38
+ "#E31A1C", # red (CS4)
39
+ "#6A3D9A", # purple (CS5)
40
+ "#B15928", # brown (CS6)
41
+ "#FB9A99", # pink (CS7)
42
+ "#A6CEE3", # light blue (CS8)
43
+ "#B2DF8A", # light green (CS9)
44
+ "#FDBF6F", # light orange (CS10)
45
+ ]
46
+
47
+ # PIP line color (when not showing credible sets)
48
+ PIP_LINE_COLOR = "#FF7F00" # orange
49
+
50
+ # eQTL effect size bins - matches locuszoomr color scheme
51
+ # Format: (min_threshold, max_threshold, label, color)
52
+ # Positive effects (upward triangles)
53
+ EQTL_POSITIVE_BINS: List[Tuple[float, float, str, str]] = [
54
+ (0.3, 0.4, "0.3 : 0.4", "#8B1A1A"), # dark red/maroon
55
+ (0.2, 0.3, "0.2 : 0.3", "#FF6600"), # orange
56
+ (0.1, 0.2, "0.1 : 0.2", "#FFB347"), # light orange
57
+ ]
58
+ # Negative effects (downward triangles)
59
+ EQTL_NEGATIVE_BINS: List[Tuple[float, float, str, str]] = [
60
+ (-0.2, -0.1, "-0.2 : -0.1", "#66CDAA"), # medium aquamarine
61
+ (-0.3, -0.2, "-0.3 : -0.2", "#4682B4"), # steel blue
62
+ (-0.4, -0.3, "-0.4 : -0.3", "#00008B"), # dark blue
63
+ ]
64
+
65
+
66
+ def get_eqtl_color(effect: Optional[float]) -> str:
67
+ """Get color based on eQTL effect size.
68
+
69
+ Args:
70
+ effect: Effect size (beta coefficient).
71
+
72
+ Returns:
73
+ Hex color code string.
74
+ """
75
+ if _is_missing(effect):
76
+ return LD_NA_COLOR
77
+
78
+ if effect >= 0:
79
+ for min_t, max_t, _, color in EQTL_POSITIVE_BINS:
80
+ if min_t <= effect < max_t or (max_t == 0.4 and effect >= max_t):
81
+ return color
82
+ return EQTL_POSITIVE_BINS[-1][3] # smallest positive bin
83
+ else:
84
+ for min_t, max_t, _, color in EQTL_NEGATIVE_BINS:
85
+ if min_t < effect <= max_t or (min_t == -0.4 and effect <= min_t):
86
+ return color
87
+ return EQTL_NEGATIVE_BINS[-1][3] # smallest negative bin
88
+
89
+
90
+ def get_eqtl_bin(effect: Optional[float]) -> str:
91
+ """Get eQTL effect bin label.
92
+
93
+ Args:
94
+ effect: Effect size (beta coefficient).
95
+
96
+ Returns:
97
+ Bin label string.
98
+ """
99
+ if _is_missing(effect):
100
+ return LD_NA_LABEL
101
+
102
+ if effect >= 0:
103
+ for min_t, max_t, label, _ in EQTL_POSITIVE_BINS:
104
+ if min_t <= effect < max_t or (max_t == 0.4 and effect >= max_t):
105
+ return label
106
+ return EQTL_POSITIVE_BINS[-1][2]
107
+ else:
108
+ for min_t, max_t, label, _ in EQTL_NEGATIVE_BINS:
109
+ if min_t < effect <= max_t or (min_t == -0.4 and effect <= min_t):
110
+ return label
111
+ return EQTL_NEGATIVE_BINS[-1][2]
112
+
113
+
114
+ def get_eqtl_color_palette() -> dict[str, str]:
115
+ """Get color palette for eQTL effect bins.
116
+
117
+ Returns:
118
+ Dictionary mapping bin labels to hex colors.
119
+ """
120
+ palette = {}
121
+ for _, _, label, color in EQTL_POSITIVE_BINS:
122
+ palette[label] = color
123
+ for _, _, label, color in EQTL_NEGATIVE_BINS:
124
+ palette[label] = color
125
+ return palette
126
+
32
127
 
33
128
  def get_ld_color(r2: Optional[float]) -> str:
34
129
  """Get LocusZoom-style color based on LD R² value.
@@ -105,3 +200,40 @@ def get_ld_color_palette() -> dict[str, str]:
105
200
  palette = {label: color for _, label, color in LD_BINS}
106
201
  palette[LD_NA_LABEL] = LD_NA_COLOR
107
202
  return palette
203
+
204
+
205
+ def get_credible_set_color(cs_id: int) -> str:
206
+ """Get color for a credible set.
207
+
208
+ Args:
209
+ cs_id: Credible set ID (1-indexed).
210
+
211
+ Returns:
212
+ Hex color code string.
213
+
214
+ Example:
215
+ >>> get_credible_set_color(1)
216
+ '#FF7F00'
217
+ """
218
+ if cs_id < 1:
219
+ return LD_NA_COLOR
220
+ # Use modulo to cycle through colors if more than 10 credible sets
221
+ idx = (cs_id - 1) % len(CREDIBLE_SET_COLORS)
222
+ return CREDIBLE_SET_COLORS[idx]
223
+
224
+
225
+ def get_credible_set_color_palette(n_sets: int = 10) -> dict[int, str]:
226
+ """Get color palette for credible sets.
227
+
228
+ Args:
229
+ n_sets: Number of credible sets to include.
230
+
231
+ Returns:
232
+ Dictionary mapping credible set IDs (1-indexed) to hex colors.
233
+
234
+ Example:
235
+ >>> palette = get_credible_set_color_palette(3)
236
+ >>> palette[1]
237
+ '#FF7F00'
238
+ """
239
+ return {i + 1: CREDIBLE_SET_COLORS[i % len(CREDIBLE_SET_COLORS)] for i in range(n_sets)}
pylocuszoom/eqtl.py CHANGED
@@ -11,7 +11,6 @@ import pandas as pd
11
11
 
12
12
  from .logging import logger
13
13
 
14
-
15
14
  REQUIRED_EQTL_COLS = ["pos", "p_value"]
16
15
  OPTIONAL_EQTL_COLS = ["gene", "effect_size", "rs", "se"]
17
16
 
@@ -109,7 +108,9 @@ def filter_eqtl_by_region(
109
108
  mask = mask & (df_chrom == chrom_str)
110
109
 
111
110
  filtered = df[mask].copy()
112
- logger.debug(f"Filtered eQTL data to {len(filtered)} variants in region chr{chrom}:{start}-{end}")
111
+ logger.debug(
112
+ f"Filtered eQTL data to {len(filtered)} variants in region chr{chrom}:{start}-{end}"
113
+ )
113
114
  return filtered
114
115
 
115
116