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.
pylocuszoom/plotter.py CHANGED
@@ -19,7 +19,6 @@ from matplotlib.axes import Axes
19
19
  from matplotlib.figure import Figure
20
20
  from matplotlib.lines import Line2D
21
21
  from matplotlib.patches import Patch
22
- from matplotlib.ticker import FuncFormatter, MaxNLocator
23
22
 
24
23
  from .backends import BackendType, get_backend
25
24
  from .colors import (
@@ -38,11 +37,16 @@ from .finemapping import (
38
37
  get_credible_sets,
39
38
  prepare_finemapping_for_plotting,
40
39
  )
41
- from .gene_track import assign_gene_positions, plot_gene_track
40
+ from .gene_track import (
41
+ assign_gene_positions,
42
+ plot_gene_track,
43
+ plot_gene_track_generic,
44
+ )
42
45
  from .labels import add_snp_labels
43
46
  from .ld import calculate_ld, find_plink
44
47
  from .logging import enable_logging, logger
45
48
  from .recombination import (
49
+ RECOMB_COLOR,
46
50
  add_recombination_overlay,
47
51
  download_canine_recombination_maps,
48
52
  get_default_data_dir,
@@ -50,8 +54,8 @@ from .recombination import (
50
54
  )
51
55
  from .utils import normalize_chrom, validate_genes_df, validate_gwas_df
52
56
 
53
- # Default significance threshold: 5e-8 for human, 5e-7 for canine
54
- DEFAULT_GENOMEWIDE_THRESHOLD = 5e-7
57
+ # Default significance threshold: 5e-8 (genome-wide significance)
58
+ DEFAULT_GENOMEWIDE_THRESHOLD = 5e-8
55
59
  DEFAULT_GENOMEWIDE_LINE = -np.log10(DEFAULT_GENOMEWIDE_THRESHOLD)
56
60
 
57
61
 
@@ -288,62 +292,70 @@ class LocusZoomPlotter:
288
292
  fig, ax, gene_ax = self._create_figure(genes_df, chrom, start, end, figsize)
289
293
 
290
294
  # Plot association data
291
- self._plot_association(ax, df, pos_col, ld_col, lead_pos)
295
+ self._plot_association(ax, df, pos_col, ld_col, lead_pos, rs_col, p_col)
292
296
 
293
297
  # Add significance line
294
- ax.axhline(
298
+ self._backend.axhline(
299
+ ax,
295
300
  y=self._genomewide_line,
296
301
  color="red",
297
- linestyle=(0, (5, 10)),
302
+ linestyle="--",
298
303
  linewidth=1,
299
- alpha=0.8,
304
+ alpha=0.65,
300
305
  zorder=1,
301
306
  )
302
307
 
303
- # Add SNP labels
308
+ # Add SNP labels (matplotlib only - interactive backends use hover tooltips)
304
309
  if snp_labels and rs_col in df.columns and label_top_n > 0 and not df.empty:
305
- add_snp_labels(
306
- ax,
307
- df,
308
- pos_col=pos_col,
309
- neglog10p_col="neglog10p",
310
- rs_col=rs_col,
311
- label_top_n=label_top_n,
312
- genes_df=genes_df,
313
- chrom=chrom,
314
- )
310
+ if self.backend_name == "matplotlib":
311
+ add_snp_labels(
312
+ ax,
313
+ df,
314
+ pos_col=pos_col,
315
+ neglog10p_col="neglog10p",
316
+ rs_col=rs_col,
317
+ label_top_n=label_top_n,
318
+ genes_df=genes_df,
319
+ chrom=chrom,
320
+ )
315
321
 
316
- # Add recombination overlay
322
+ # Add recombination overlay (all backends)
317
323
  if recomb_df is not None and not recomb_df.empty:
318
- add_recombination_overlay(ax, recomb_df, start, end)
324
+ if self.backend_name == "matplotlib":
325
+ add_recombination_overlay(ax, recomb_df, start, end)
326
+ else:
327
+ self._add_recombination_overlay_generic(ax, recomb_df, start, end)
319
328
 
320
329
  # Format axes
321
- ax.set_ylabel(r"$-\log_{10}$ P")
322
- ax.set_xlim(start, end)
323
- ax.spines["top"].set_visible(False)
324
- ax.spines["right"].set_visible(False)
330
+ self._backend.set_ylabel(ax, r"$-\log_{10}$ P")
331
+ self._backend.set_xlim(ax, start, end)
332
+ self._backend.hide_spines(ax, ["top", "right"])
325
333
 
326
- # Add LD legend
334
+ # Add LD legend (all backends)
327
335
  if ld_col is not None and ld_col in df.columns:
328
- self._add_ld_legend(ax)
336
+ if self.backend_name == "matplotlib":
337
+ self._add_ld_legend(ax)
338
+ else:
339
+ self._backend.add_ld_legend(ax, LD_BINS, LEAD_SNP_COLOR)
329
340
 
330
- # Plot gene track
341
+ # Plot gene track (all backends)
331
342
  if genes_df is not None and gene_ax is not None:
332
- plot_gene_track(gene_ax, genes_df, chrom, start, end, exons_df)
333
- gene_ax.set_xlabel(f"Chromosome {chrom} (Mb)")
334
- gene_ax.spines["top"].set_visible(False)
335
- gene_ax.spines["right"].set_visible(False)
336
- gene_ax.spines["left"].set_visible(False)
343
+ if self.backend_name == "matplotlib":
344
+ plot_gene_track(gene_ax, genes_df, chrom, start, end, exons_df)
345
+ else:
346
+ plot_gene_track_generic(
347
+ gene_ax, self._backend, genes_df, chrom, start, end, exons_df
348
+ )
349
+ self._backend.set_xlabel(gene_ax, f"Chromosome {chrom} (Mb)")
350
+ self._backend.hide_spines(gene_ax, ["top", "right", "left"])
337
351
  else:
338
- ax.set_xlabel(f"Chromosome {chrom} (Mb)")
352
+ self._backend.set_xlabel(ax, f"Chromosome {chrom} (Mb)")
339
353
 
340
354
  # Format x-axis with Mb labels
341
- ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x / 1e6:.2f}"))
342
- ax.xaxis.set_major_locator(MaxNLocator(nbins=6))
355
+ self._backend.format_xaxis_mb(ax)
343
356
 
344
357
  # Adjust layout
345
- fig.subplots_adjust(left=0.08, right=0.95, top=0.95, bottom=0.1, hspace=0.08)
346
- plt.ion()
358
+ self._backend.finalize_layout(fig, hspace=0.1)
347
359
 
348
360
  return fig
349
361
 
@@ -381,18 +393,20 @@ class LocusZoomPlotter:
381
393
  assoc_height = figsize[1] * 0.6
382
394
  total_height = assoc_height + gene_track_height
383
395
 
384
- fig, axes = plt.subplots(
385
- 2,
386
- 1,
387
- figsize=(figsize[0], total_height),
396
+ fig, axes = self._backend.create_figure(
397
+ n_panels=2,
388
398
  height_ratios=[assoc_height, gene_track_height],
399
+ figsize=(figsize[0], total_height),
389
400
  sharex=True,
390
- gridspec_kw={"hspace": 0},
391
401
  )
392
402
  return fig, axes[0], axes[1]
393
403
  else:
394
- fig, ax = plt.subplots(figsize=(figsize[0], figsize[1] * 0.75))
395
- return fig, ax, None
404
+ fig, axes = self._backend.create_figure(
405
+ n_panels=1,
406
+ height_ratios=[1.0],
407
+ figsize=(figsize[0], figsize[1] * 0.75),
408
+ )
409
+ return fig, axes[0], None
396
410
 
397
411
  def _plot_association(
398
412
  self,
@@ -401,8 +415,28 @@ class LocusZoomPlotter:
401
415
  pos_col: str,
402
416
  ld_col: Optional[str],
403
417
  lead_pos: Optional[int],
418
+ rs_col: Optional[str] = None,
419
+ p_col: Optional[str] = None,
404
420
  ) -> None:
405
421
  """Plot association scatter with LD coloring."""
422
+
423
+ def _build_hover_data(subset_df: pd.DataFrame) -> Optional[pd.DataFrame]:
424
+ """Build hover data for interactive backends."""
425
+ hover_cols = {}
426
+ # RS ID first (will be bold in hover)
427
+ if rs_col and rs_col in subset_df.columns:
428
+ hover_cols["SNP"] = subset_df[rs_col].values
429
+ # Position
430
+ if pos_col in subset_df.columns:
431
+ hover_cols["Position"] = subset_df[pos_col].values
432
+ # P-value
433
+ if p_col and p_col in subset_df.columns:
434
+ hover_cols["P-value"] = subset_df[p_col].values
435
+ # LD
436
+ if ld_col and ld_col in subset_df.columns:
437
+ hover_cols["R²"] = subset_df[ld_col].values
438
+ return pd.DataFrame(hover_cols) if hover_cols else None
439
+
406
440
  # LD-based coloring
407
441
  if ld_col is not None and ld_col in df.columns:
408
442
  df["ld_bin"] = df[ld_col].apply(get_ld_bin)
@@ -411,40 +445,46 @@ class LocusZoomPlotter:
411
445
  palette = get_ld_color_palette()
412
446
  for bin_label in df["ld_bin"].unique():
413
447
  bin_data = df[df["ld_bin"] == bin_label]
414
- ax.scatter(
448
+ self._backend.scatter(
449
+ ax,
415
450
  bin_data[pos_col],
416
451
  bin_data["neglog10p"],
417
- c=palette.get(bin_label, "#BEBEBE"),
418
- s=60,
452
+ colors=palette.get(bin_label, "#BEBEBE"),
453
+ sizes=60,
419
454
  edgecolor="black",
420
455
  linewidth=0.5,
421
456
  zorder=2,
457
+ hover_data=_build_hover_data(bin_data),
422
458
  )
423
459
  else:
424
460
  # Default: grey points
425
- ax.scatter(
461
+ self._backend.scatter(
462
+ ax,
426
463
  df[pos_col],
427
464
  df["neglog10p"],
428
- c="#BEBEBE",
429
- s=60,
465
+ colors="#BEBEBE",
466
+ sizes=60,
430
467
  edgecolor="black",
431
468
  linewidth=0.5,
432
469
  zorder=2,
470
+ hover_data=_build_hover_data(df),
433
471
  )
434
472
 
435
- # Highlight lead SNP
473
+ # Highlight lead SNP with larger, more prominent marker
436
474
  if lead_pos is not None:
437
475
  lead_snp = df[df[pos_col] == lead_pos]
438
476
  if not lead_snp.empty:
439
- ax.scatter(
477
+ self._backend.scatter(
478
+ ax,
440
479
  lead_snp[pos_col],
441
480
  lead_snp["neglog10p"],
442
- c=LEAD_SNP_COLOR,
443
- s=60,
481
+ colors=LEAD_SNP_COLOR,
482
+ sizes=120, # Larger than regular points for visibility
444
483
  marker="D",
445
- edgecolors="black",
446
- linewidths=1.5,
484
+ edgecolor="black",
485
+ linewidth=1.5,
447
486
  zorder=10,
487
+ hover_data=_build_hover_data(lead_snp),
448
488
  )
449
489
 
450
490
  def _add_ld_legend(self, ax: Axes) -> None:
@@ -485,6 +525,70 @@ class LocusZoomPlotter:
485
525
  labelspacing=0.4,
486
526
  )
487
527
 
528
+ def _add_recombination_overlay_generic(
529
+ self,
530
+ ax: Any,
531
+ recomb_df: pd.DataFrame,
532
+ start: int,
533
+ end: int,
534
+ ) -> None:
535
+ """Add recombination overlay for interactive backends (plotly/bokeh).
536
+
537
+ Creates a secondary y-axis with recombination rate line and fill.
538
+ """
539
+ # Filter to region
540
+ region_recomb = recomb_df[
541
+ (recomb_df["pos"] >= start) & (recomb_df["pos"] <= end)
542
+ ].copy()
543
+
544
+ if region_recomb.empty:
545
+ return
546
+
547
+ # Create secondary y-axis
548
+ yaxis_name = self._backend.create_twin_axis(ax)
549
+
550
+ # For plotly, yaxis_name is a tuple (fig, row, secondary_y)
551
+ # For bokeh, yaxis_name is just a string
552
+ if isinstance(yaxis_name, tuple):
553
+ _, _, secondary_y = yaxis_name
554
+ else:
555
+ secondary_y = yaxis_name
556
+
557
+ # Plot fill under curve
558
+ self._backend.fill_between_secondary(
559
+ ax,
560
+ region_recomb["pos"],
561
+ 0,
562
+ region_recomb["rate"],
563
+ color=RECOMB_COLOR,
564
+ alpha=0.15,
565
+ yaxis_name=secondary_y,
566
+ )
567
+
568
+ # Plot recombination rate line
569
+ self._backend.line_secondary(
570
+ ax,
571
+ region_recomb["pos"],
572
+ region_recomb["rate"],
573
+ color=RECOMB_COLOR,
574
+ linewidth=1.5,
575
+ alpha=0.7,
576
+ yaxis_name=secondary_y,
577
+ )
578
+
579
+ # Set y-axis limits and label
580
+ max_rate = region_recomb["rate"].max()
581
+ self._backend.set_secondary_ylim(
582
+ ax, 0, max(max_rate * 1.2, 20), yaxis_name=secondary_y
583
+ )
584
+ self._backend.set_secondary_ylabel(
585
+ ax,
586
+ "Recombination rate (cM/Mb)",
587
+ color=RECOMB_COLOR,
588
+ fontsize=9,
589
+ yaxis_name=secondary_y,
590
+ )
591
+
488
592
  def _add_eqtl_legend(self, ax: Axes) -> None:
489
593
  """Add eQTL effect size legend to plot."""
490
594
  legend_elements = []
@@ -557,7 +661,8 @@ class LocusZoomPlotter:
557
661
  df = df.sort_values(pos_col)
558
662
 
559
663
  # Plot PIP as line
560
- ax.plot(
664
+ self._backend.line(
665
+ ax,
561
666
  df[pos_col],
562
667
  df[pip_col],
563
668
  color=PIP_LINE_COLOR,
@@ -575,11 +680,12 @@ class LocusZoomPlotter:
575
680
  for cs_id in credible_sets:
576
681
  cs_data = df[df[cs_col] == cs_id]
577
682
  color = get_credible_set_color(cs_id)
578
- ax.scatter(
683
+ self._backend.scatter(
684
+ ax,
579
685
  cs_data[pos_col],
580
686
  cs_data[pip_col],
581
- c=color,
582
- s=50,
687
+ colors=color,
688
+ sizes=50,
583
689
  marker="o",
584
690
  edgecolor="black",
585
691
  linewidth=0.5,
@@ -591,27 +697,28 @@ class LocusZoomPlotter:
591
697
  if not non_cs_data.empty and pip_threshold > 0:
592
698
  non_cs_data = non_cs_data[non_cs_data[pip_col] >= pip_threshold]
593
699
  if not non_cs_data.empty:
594
- ax.scatter(
700
+ self._backend.scatter(
701
+ ax,
595
702
  non_cs_data[pos_col],
596
703
  non_cs_data[pip_col],
597
- c="#BEBEBE",
598
- s=30,
704
+ colors="#BEBEBE",
705
+ sizes=30,
599
706
  marker="o",
600
707
  edgecolor="black",
601
708
  linewidth=0.3,
602
709
  zorder=2,
603
- alpha=0.6,
604
710
  )
605
711
  else:
606
712
  # No credible sets - show all points above threshold
607
713
  if pip_threshold > 0:
608
714
  high_pip = df[df[pip_col] >= pip_threshold]
609
715
  if not high_pip.empty:
610
- ax.scatter(
716
+ self._backend.scatter(
717
+ ax,
611
718
  high_pip[pos_col],
612
719
  high_pip[pip_col],
613
- c=PIP_LINE_COLOR,
614
- s=50,
720
+ colors=PIP_LINE_COLOR,
721
+ sizes=50,
615
722
  marker="o",
616
723
  edgecolor="black",
617
724
  linewidth=0.5,
@@ -825,24 +932,17 @@ class LocusZoomPlotter:
825
932
  f"Creating stacked plot with {n_panels} panels for chr{chrom}:{start}-{end}"
826
933
  )
827
934
 
828
- # Prevent auto-display in interactive environments
829
- plt.ioff()
830
-
831
935
  # Load recombination data if needed
832
936
  if show_recombination and recomb_df is None:
833
937
  recomb_df = self._get_recomb_for_region(chrom, start, end)
834
938
 
835
- # Create figure
836
- fig, axes = plt.subplots(
837
- n_panels,
838
- 1,
839
- figsize=actual_figsize,
939
+ # Create figure using backend
940
+ fig, axes = self._backend.create_figure(
941
+ n_panels=n_panels,
840
942
  height_ratios=height_ratios,
943
+ figsize=actual_figsize,
841
944
  sharex=True,
842
- gridspec_kw={"hspace": 0.05},
843
945
  )
844
- if n_panels == 1:
845
- axes = [axes]
846
946
 
847
947
  # Plot each GWAS panel
848
948
  for i, (gwas_df, lead_pos) in enumerate(zip(gwas_dfs, lead_positions)):
@@ -868,56 +968,91 @@ class LocusZoomPlotter:
868
968
  panel_ld_col = "R2"
869
969
 
870
970
  # Plot association
871
- self._plot_association(ax, df, pos_col, panel_ld_col, lead_pos)
971
+ self._plot_association(ax, df, pos_col, panel_ld_col, lead_pos, rs_col, p_col)
872
972
 
873
973
  # Add significance line
874
- ax.axhline(
974
+ self._backend.axhline(
975
+ ax,
875
976
  y=self._genomewide_line,
876
977
  color="red",
877
978
  linestyle="--",
878
979
  linewidth=1,
879
- alpha=0.8,
980
+ alpha=0.65,
880
981
  zorder=1,
881
982
  )
882
983
 
883
- # Add SNP labels
984
+ # Add SNP labels (matplotlib only - interactive backends use hover tooltips)
884
985
  if snp_labels and rs_col in df.columns and label_top_n > 0 and not df.empty:
885
- add_snp_labels(
886
- ax,
887
- df,
888
- pos_col=pos_col,
889
- neglog10p_col="neglog10p",
890
- rs_col=rs_col,
891
- label_top_n=label_top_n,
892
- genes_df=genes_df,
893
- chrom=chrom,
894
- )
986
+ if self.backend_name == "matplotlib":
987
+ add_snp_labels(
988
+ ax,
989
+ df,
990
+ pos_col=pos_col,
991
+ neglog10p_col="neglog10p",
992
+ rs_col=rs_col,
993
+ label_top_n=label_top_n,
994
+ genes_df=genes_df,
995
+ chrom=chrom,
996
+ )
895
997
 
896
- # Add recombination overlay (only on first panel)
998
+ # Add recombination overlay (only on first panel, all backends)
897
999
  if i == 0 and recomb_df is not None and not recomb_df.empty:
898
- add_recombination_overlay(ax, recomb_df, start, end)
1000
+ if self.backend_name == "matplotlib":
1001
+ add_recombination_overlay(ax, recomb_df, start, end)
1002
+ else:
1003
+ self._add_recombination_overlay_generic(ax, recomb_df, start, end)
899
1004
 
900
1005
  # Format axes
901
- ax.set_ylabel(r"$-\log_{10}$ P")
902
- ax.set_xlim(start, end)
903
- ax.spines["top"].set_visible(False)
904
- ax.spines["right"].set_visible(False)
1006
+ self._backend.set_ylabel(ax, r"$-\log_{10}$ P")
1007
+ self._backend.set_xlim(ax, start, end)
1008
+ self._backend.hide_spines(ax, ["top", "right"])
905
1009
 
906
1010
  # Add panel label
907
1011
  if panel_labels and i < len(panel_labels):
908
- ax.annotate(
909
- panel_labels[i],
910
- xy=(0.02, 0.95),
911
- xycoords="axes fraction",
912
- fontsize=11,
913
- fontweight="bold",
914
- va="top",
915
- ha="left",
916
- )
1012
+ if self.backend_name == "matplotlib":
1013
+ ax.annotate(
1014
+ panel_labels[i],
1015
+ xy=(0.02, 0.95),
1016
+ xycoords="axes fraction",
1017
+ fontsize=11,
1018
+ fontweight="bold",
1019
+ va="top",
1020
+ ha="left",
1021
+ )
1022
+ elif self.backend_name == "plotly":
1023
+ fig, row = ax
1024
+ fig.add_annotation(
1025
+ text=f"<b>{panel_labels[i]}</b>",
1026
+ xref=f"x{row} domain" if row > 1 else "x domain",
1027
+ yref=f"y{row} domain" if row > 1 else "y domain",
1028
+ x=0.02,
1029
+ y=0.95,
1030
+ showarrow=False,
1031
+ font=dict(size=11),
1032
+ xanchor="left",
1033
+ yanchor="top",
1034
+ )
1035
+ elif self.backend_name == "bokeh":
1036
+ from bokeh.models import Label
1037
+
1038
+ # Get y-axis range for positioning
1039
+ y_max = ax.y_range.end if ax.y_range.end else 10
1040
+ x_min = ax.x_range.start if ax.x_range.start else start
1041
+ label = Label(
1042
+ x=x_min + (end - start) * 0.02,
1043
+ y=y_max * 0.95,
1044
+ text=panel_labels[i],
1045
+ text_font_size="11pt",
1046
+ text_font_style="bold",
1047
+ )
1048
+ ax.add_layout(label)
917
1049
 
918
- # Add LD legend (only on first panel)
1050
+ # Add LD legend (only on first panel, all backends)
919
1051
  if i == 0 and panel_ld_col is not None and panel_ld_col in df.columns:
920
- self._add_ld_legend(ax)
1052
+ if self.backend_name == "matplotlib":
1053
+ self._add_ld_legend(ax)
1054
+ else:
1055
+ self._backend.add_ld_legend(ax, LD_BINS, LEAD_SNP_COLOR)
921
1056
 
922
1057
  # Track current panel index
923
1058
  panel_idx = n_gwas
@@ -950,10 +1085,9 @@ class LocusZoomPlotter:
950
1085
  if credible_sets:
951
1086
  self._add_finemapping_legend(ax, credible_sets)
952
1087
 
953
- ax.set_ylabel("PIP")
954
- ax.set_ylim(-0.05, 1.05)
955
- ax.spines["top"].set_visible(False)
956
- ax.spines["right"].set_visible(False)
1088
+ self._backend.set_ylabel(ax, "PIP")
1089
+ self._backend.set_ylim(ax, -0.05, 1.05)
1090
+ self._backend.hide_spines(ax, ["top", "right"])
957
1091
  panel_idx += 1
958
1092
 
959
1093
  # Plot eQTL panel if provided
@@ -986,11 +1120,12 @@ class LocusZoomPlotter:
986
1120
  effect = row["effect_size"]
987
1121
  color = get_eqtl_color(effect)
988
1122
  marker = "^" if effect >= 0 else "v"
989
- ax.scatter(
990
- row["pos"],
991
- row["neglog10p"],
992
- c=color,
993
- s=50,
1123
+ self._backend.scatter(
1124
+ ax,
1125
+ pd.Series([row["pos"]]),
1126
+ pd.Series([row["neglog10p"]]),
1127
+ colors=color,
1128
+ sizes=50,
994
1129
  marker=marker,
995
1130
  edgecolor="black",
996
1131
  linewidth=0.5,
@@ -1000,11 +1135,12 @@ class LocusZoomPlotter:
1000
1135
  self._add_eqtl_legend(ax)
1001
1136
  else:
1002
1137
  # No effect sizes - plot as diamonds
1003
- ax.scatter(
1138
+ self._backend.scatter(
1139
+ ax,
1004
1140
  eqtl_data["pos"],
1005
1141
  eqtl_data["neglog10p"],
1006
- c="#FF6B6B",
1007
- s=60,
1142
+ colors="#FF6B6B",
1143
+ sizes=60,
1008
1144
  marker="D",
1009
1145
  edgecolor="black",
1010
1146
  linewidth=0.5,
@@ -1013,36 +1149,38 @@ class LocusZoomPlotter:
1013
1149
  )
1014
1150
  ax.legend(loc="upper right", fontsize=9)
1015
1151
 
1016
- ax.set_ylabel(r"$-\log_{10}$ P (eQTL)")
1017
- ax.axhline(
1152
+ self._backend.set_ylabel(ax, r"$-\log_{10}$ P (eQTL)")
1153
+ self._backend.axhline(
1154
+ ax,
1018
1155
  y=self._genomewide_line,
1019
1156
  color="red",
1020
1157
  linestyle="--",
1021
1158
  linewidth=1,
1022
- alpha=0.8,
1159
+ alpha=0.65,
1023
1160
  )
1024
- ax.spines["top"].set_visible(False)
1025
- ax.spines["right"].set_visible(False)
1161
+ self._backend.hide_spines(ax, ["top", "right"])
1026
1162
  panel_idx += 1
1027
1163
 
1028
- # Plot gene track
1164
+ # Plot gene track (all backends)
1029
1165
  if genes_df is not None:
1030
1166
  gene_ax = axes[panel_idx]
1031
- plot_gene_track(gene_ax, genes_df, chrom, start, end, exons_df)
1032
- gene_ax.set_xlabel(f"Chromosome {chrom} (Mb)")
1033
- gene_ax.spines["top"].set_visible(False)
1034
- gene_ax.spines["right"].set_visible(False)
1035
- gene_ax.spines["left"].set_visible(False)
1167
+ if self.backend_name == "matplotlib":
1168
+ plot_gene_track(gene_ax, genes_df, chrom, start, end, exons_df)
1169
+ else:
1170
+ plot_gene_track_generic(
1171
+ gene_ax, self._backend, genes_df, chrom, start, end, exons_df
1172
+ )
1173
+ self._backend.set_xlabel(gene_ax, f"Chromosome {chrom} (Mb)")
1174
+ self._backend.hide_spines(gene_ax, ["top", "right", "left"])
1036
1175
  else:
1037
1176
  # Set x-label on bottom panel
1038
- axes[-1].set_xlabel(f"Chromosome {chrom} (Mb)")
1177
+ self._backend.set_xlabel(axes[-1], f"Chromosome {chrom} (Mb)")
1039
1178
 
1040
- # Format x-axis
1041
- axes[0].xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{x / 1e6:.2f}"))
1042
- axes[0].xaxis.set_major_locator(MaxNLocator(nbins=6))
1179
+ # Format x-axis (call for all axes - Plotly needs each subplot formatted)
1180
+ for ax in axes:
1181
+ self._backend.format_xaxis_mb(ax)
1043
1182
 
1044
1183
  # Adjust layout
1045
- fig.subplots_adjust(left=0.08, right=0.95, top=0.95, bottom=0.08, hspace=0.05)
1046
- plt.ion()
1184
+ self._backend.finalize_layout(fig, hspace=0.1)
1047
1185
 
1048
1186
  return fig