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.
@@ -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(
@@ -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,6 +350,8 @@ class PlotlyBackend:
317
350
  """Set x-axis label."""
318
351
  fig, row = ax
319
352
  xaxis = f"xaxis{row}" if row > 1 else "xaxis"
353
+ # Convert LaTeX-style labels to Unicode for Plotly
354
+ label = self._convert_label(label)
320
355
  fig.update_layout(
321
356
  **{xaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
322
357
  )
@@ -327,10 +362,29 @@ class PlotlyBackend:
327
362
  """Set y-axis label."""
328
363
  fig, row = ax
329
364
  yaxis = f"yaxis{row}" if row > 1 else "yaxis"
365
+ # Convert LaTeX-style labels to Unicode for Plotly
366
+ label = self._convert_label(label)
330
367
  fig.update_layout(
331
368
  **{yaxis: dict(title=dict(text=label, font=dict(size=fontsize)))}
332
369
  )
333
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
387
+
334
388
  def set_title(
335
389
  self, ax: Tuple[go.Figure, int], title: str, fontsize: int = 14
336
390
  ) -> None:
@@ -360,6 +414,146 @@ class PlotlyBackend:
360
414
 
361
415
  return (fig, row, secondary_y)
362
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
+
363
557
  def add_legend(
364
558
  self,
365
559
  ax: Tuple[go.Figure, int],
@@ -404,22 +598,15 @@ class PlotlyBackend:
404
598
  pass
405
599
 
406
600
  def format_xaxis_mb(self, ax: Tuple[go.Figure, int]) -> None:
407
- """Format x-axis to show megabase values."""
408
- fig, row = ax
409
- xaxis = f"xaxis{row}" if row > 1 else "xaxis"
601
+ """Format x-axis to show megabase values.
410
602
 
411
- fig.update_layout(
412
- **{
413
- xaxis: dict(
414
- tickformat=".2f",
415
- ticksuffix=" Mb",
416
- tickvals=None, # Auto
417
- )
418
- }
419
- )
420
-
421
- # Apply custom tick formatting via ticktext/tickvals if needed
422
- # 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)
423
610
 
424
611
  def save(
425
612
  self,
@@ -456,7 +643,7 @@ class PlotlyBackend:
456
643
  bottom: float = 0.1,
457
644
  hspace: float = 0.08,
458
645
  ) -> None:
459
- """Adjust layout margins.
646
+ """Adjust layout margins and apply Mb tick formatting.
460
647
 
461
648
  Args:
462
649
  fig: Figure object.
@@ -471,3 +658,58 @@ class PlotlyBackend:
471
658
  b=int(bottom * fig.layout.height) if fig.layout.height else 80,
472
659
  )
473
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
+ )
@@ -6,7 +6,6 @@ fine-mapping results (SuSiE, FINEMAP, etc.) for visualization.
6
6
 
7
7
  from typing import List, Optional
8
8
 
9
- import numpy as np
10
9
  import pandas as pd
11
10
 
12
11
  from .logging import logger
pylocuszoom/gene_track.py CHANGED
@@ -7,7 +7,7 @@ Provides LocusZoom-style gene track plotting with:
7
7
  - Gene name labels
8
8
  """
9
9
 
10
- from typing import List, Optional, Union
10
+ from typing import Any, List, Optional, Union
11
11
 
12
12
  import pandas as pd
13
13
  from matplotlib.axes import Axes
@@ -17,15 +17,15 @@ from .utils import normalize_chrom
17
17
 
18
18
  # Strand-specific colors (distinct from LD palette)
19
19
  STRAND_COLORS: dict[Optional[str], str] = {
20
- "+": "#FFD700", # Gold/bright yellow for forward strand
21
- "-": "#DDA0DD", # Plum/light purple for reverse strand
20
+ "+": "#DAA520", # Goldenrod for forward strand
21
+ "-": "#6BB3FF", # Light blue for reverse strand
22
22
  None: "#999999", # Light grey if no strand info
23
23
  }
24
24
 
25
25
  # Layout constants
26
- ROW_HEIGHT = 0.40 # Total height per row
27
- GENE_AREA = 0.28 # Bottom portion for gene drawing
28
- EXON_HEIGHT = 0.22 # Exon rectangle height
26
+ ROW_HEIGHT = 0.35 # Total height per row (reduced for tighter spacing)
27
+ GENE_AREA = 0.25 # Bottom portion for gene drawing
28
+ EXON_HEIGHT = 0.20 # Exon rectangle height
29
29
  INTRON_HEIGHT = 0.02 # Thin intron line
30
30
 
31
31
 
@@ -175,7 +175,7 @@ def plot_gene_track(
175
175
  # Set y-axis limits - small bottom margin for gene body, tight top
176
176
  max_row = max(positions) if positions else 0
177
177
  bottom_margin = EXON_HEIGHT / 2 + 0.02 # Room for bottom gene
178
- top_margin = 0.15 # Small space above top label
178
+ top_margin = 0.05 # Minimal space above top label
179
179
  ax.set_ylim(
180
180
  -bottom_margin,
181
181
  (max_row + 1) * ROW_HEIGHT - ROW_HEIGHT + GENE_AREA + top_margin,
@@ -193,6 +193,8 @@ def plot_gene_track(
193
193
  & (exons_df["start"] <= end)
194
194
  ].copy()
195
195
 
196
+ region_width = end - start
197
+
196
198
  for idx, (_, gene) in enumerate(region_genes.iterrows()):
197
199
  gene_start = max(int(gene["start"]), start)
198
200
  gene_end = min(int(gene["end"]), end)
@@ -258,38 +260,41 @@ def plot_gene_track(
258
260
  # Add strand direction triangles (tip, center, tail)
259
261
  if "strand" in gene.index:
260
262
  strand = gene["strand"]
261
- region_width = end - start
262
- gene_width = gene_end - gene_start
263
263
  arrow_dir = 1 if strand == "+" else -1
264
264
 
265
265
  # Triangle dimensions
266
266
  tri_height = EXON_HEIGHT * 0.35
267
267
  tri_width = region_width * 0.006
268
268
 
269
- # Arrow positions: front, middle, back
269
+ # Arrow positions: front, middle, back (tip positions)
270
+ tip_offset = tri_width / 2 # Tiny offset to keep tip inside gene
271
+ tail_offset = tri_width * 1.5 # Offset for tail arrow from gene start/end
272
+ gene_center = (gene_start + gene_end) / 2
270
273
  if arrow_dir == 1: # Forward strand
271
- arrow_positions = [
272
- gene_start, # Front
273
- (gene_start + gene_end) / 2, # Middle
274
- gene_end, # Back (tip past gene end)
274
+ arrow_tip_positions = [
275
+ gene_start + tail_offset, # Tail (tip inside gene)
276
+ gene_center + tri_width / 2, # Middle (arrow center at gene center)
277
+ gene_end - tip_offset, # Tip (near gene end)
275
278
  ]
279
+ arrow_color = "#000000" # Black for forward
276
280
  else: # Reverse strand
277
- arrow_positions = [
278
- gene_end, # Front (arrows point left, so start from right)
279
- (gene_start + gene_end) / 2, # Middle
280
- gene_start, # Back (tip past gene start)
281
+ arrow_tip_positions = [
282
+ gene_end - tail_offset, # Tail (tip inside gene)
283
+ gene_center - tri_width / 2, # Middle (arrow center at gene center)
284
+ gene_start + tip_offset, # Tip (near gene start)
281
285
  ]
286
+ arrow_color = "#333333" # Dark grey for reverse
282
287
 
283
- for base_x in arrow_positions:
288
+ for tip_x in arrow_tip_positions:
284
289
  if arrow_dir == 1:
285
- tip_x = base_x + tri_width
290
+ base_x = tip_x - tri_width
286
291
  tri_points = [
287
292
  [tip_x, y_gene], # Tip pointing right
288
293
  [base_x, y_gene + tri_height],
289
294
  [base_x, y_gene - tri_height],
290
295
  ]
291
296
  else:
292
- tip_x = base_x - tri_width
297
+ base_x = tip_x + tri_width
293
298
  tri_points = [
294
299
  [tip_x, y_gene], # Tip pointing left
295
300
  [base_x, y_gene + tri_height],
@@ -299,8 +304,8 @@ def plot_gene_track(
299
304
  triangle = Polygon(
300
305
  tri_points,
301
306
  closed=True,
302
- facecolor="#000000",
303
- edgecolor="#000000",
307
+ facecolor=arrow_color,
308
+ edgecolor=arrow_color,
304
309
  linewidth=0.5,
305
310
  zorder=5,
306
311
  )
@@ -322,3 +327,206 @@ def plot_gene_track(
322
327
  zorder=4,
323
328
  clip_on=True,
324
329
  )
330
+
331
+
332
+ def plot_gene_track_generic(
333
+ ax: Any,
334
+ backend: Any,
335
+ genes_df: pd.DataFrame,
336
+ chrom: Union[int, str],
337
+ start: int,
338
+ end: int,
339
+ exons_df: Optional[pd.DataFrame] = None,
340
+ ) -> None:
341
+ """Plot gene annotations using a backend-agnostic approach.
342
+
343
+ This function works with matplotlib, plotly, and bokeh backends.
344
+
345
+ Args:
346
+ ax: Axes object (format depends on backend).
347
+ backend: Backend instance with drawing methods.
348
+ genes_df: Gene annotations with chr, start, end, gene_name,
349
+ and optionally strand (+/-) column.
350
+ chrom: Chromosome number or string.
351
+ start: Region start position.
352
+ end: Region end position.
353
+ exons_df: Exon annotations with chr, start, end, gene_name
354
+ columns for drawing exon structure. Optional.
355
+ """
356
+ chrom_str = normalize_chrom(chrom)
357
+ region_genes = genes_df[
358
+ (genes_df["chr"].astype(str).str.replace("chr", "", regex=False) == chrom_str)
359
+ & (genes_df["end"] >= start)
360
+ & (genes_df["start"] <= end)
361
+ ].copy()
362
+
363
+ backend.set_xlim(ax, start, end)
364
+ backend.set_ylabel(ax, "", fontsize=10)
365
+
366
+ if region_genes.empty:
367
+ backend.set_ylim(ax, 0, 1)
368
+ backend.add_text(
369
+ ax,
370
+ (start + end) / 2,
371
+ 0.5,
372
+ "No genes",
373
+ fontsize=9,
374
+ ha="center",
375
+ va="center",
376
+ color="grey",
377
+ )
378
+ return
379
+
380
+ # Assign vertical positions to avoid overlap
381
+ region_genes = region_genes.sort_values("start")
382
+ positions = assign_gene_positions(region_genes, start, end)
383
+
384
+ # Set y-axis limits - small bottom margin for gene body, tight top
385
+ max_row = max(positions) if positions else 0
386
+ bottom_margin = EXON_HEIGHT / 2 + 0.02 # Room for bottom gene
387
+ top_margin = 0.05 # Minimal space above top label
388
+ backend.set_ylim(
389
+ ax,
390
+ -bottom_margin,
391
+ (max_row + 1) * ROW_HEIGHT - ROW_HEIGHT + GENE_AREA + top_margin,
392
+ )
393
+
394
+ # Filter exons for this region if available
395
+ region_exons = None
396
+ if exons_df is not None and not exons_df.empty:
397
+ region_exons = exons_df[
398
+ (
399
+ exons_df["chr"].astype(str).str.replace("chr", "", regex=False)
400
+ == chrom_str
401
+ )
402
+ & (exons_df["end"] >= start)
403
+ & (exons_df["start"] <= end)
404
+ ].copy()
405
+
406
+ region_width = end - start
407
+
408
+ for idx, (_, gene) in enumerate(region_genes.iterrows()):
409
+ gene_start = max(int(gene["start"]), start)
410
+ gene_end = min(int(gene["end"]), end)
411
+ row = positions[idx]
412
+ gene_name = gene.get("gene_name", "")
413
+
414
+ # Get strand-specific color
415
+ strand = gene.get("strand") if "strand" in gene.index else None
416
+ gene_col = STRAND_COLORS.get(strand, STRAND_COLORS[None])
417
+
418
+ # Y position: bottom of row + offset for gene area
419
+ y_gene = row * ROW_HEIGHT + 0.05
420
+ y_label = y_gene + EXON_HEIGHT / 2 + 0.01 # Just above gene top
421
+
422
+ # Check if we have exon data for this gene
423
+ gene_exons = None
424
+ if region_exons is not None and not region_exons.empty and gene_name:
425
+ gene_exons = region_exons[region_exons["gene_name"] == gene_name].copy()
426
+
427
+ if gene_exons is not None and not gene_exons.empty:
428
+ # Draw intron line (thin horizontal line spanning gene)
429
+ backend.add_rectangle(
430
+ ax,
431
+ (gene_start, y_gene - INTRON_HEIGHT / 2),
432
+ gene_end - gene_start,
433
+ INTRON_HEIGHT,
434
+ facecolor=gene_col,
435
+ edgecolor=gene_col,
436
+ linewidth=0.5,
437
+ zorder=1,
438
+ )
439
+
440
+ # Draw exons (thick rectangles)
441
+ for _, exon in gene_exons.iterrows():
442
+ exon_start = max(int(exon["start"]), start)
443
+ exon_end = min(int(exon["end"]), end)
444
+ backend.add_rectangle(
445
+ ax,
446
+ (exon_start, y_gene - EXON_HEIGHT / 2),
447
+ exon_end - exon_start,
448
+ EXON_HEIGHT,
449
+ facecolor=gene_col,
450
+ edgecolor=gene_col,
451
+ linewidth=0.5,
452
+ zorder=2,
453
+ )
454
+ else:
455
+ # No exon data - draw full gene body as rectangle (fallback)
456
+ backend.add_rectangle(
457
+ ax,
458
+ (gene_start, y_gene - EXON_HEIGHT / 2),
459
+ gene_end - gene_start,
460
+ EXON_HEIGHT,
461
+ facecolor=gene_col,
462
+ edgecolor=gene_col,
463
+ linewidth=0.5,
464
+ zorder=2,
465
+ )
466
+
467
+ # Add strand direction triangles (tip, center, tail)
468
+ if "strand" in gene.index:
469
+ strand = gene["strand"]
470
+ arrow_dir = 1 if strand == "+" else -1
471
+
472
+ # Triangle dimensions
473
+ tri_height = EXON_HEIGHT * 0.35
474
+ tri_width = region_width * 0.006
475
+
476
+ # Arrow positions: front, middle, back (tip positions)
477
+ tip_offset = tri_width / 2 # Tiny offset to keep tip inside gene
478
+ tail_offset = tri_width * 1.5 # Offset for tail arrow from gene start/end
479
+ gene_center = (gene_start + gene_end) / 2
480
+ if arrow_dir == 1: # Forward strand
481
+ arrow_tip_positions = [
482
+ gene_start + tail_offset, # Tail (tip inside gene)
483
+ gene_center + tri_width / 2, # Middle (arrow center at gene center)
484
+ gene_end - tip_offset, # Tip (near gene end)
485
+ ]
486
+ arrow_color = "#000000" # Black for forward
487
+ else: # Reverse strand
488
+ arrow_tip_positions = [
489
+ gene_end - tail_offset, # Tail (tip inside gene)
490
+ gene_center - tri_width / 2, # Middle (arrow center at gene center)
491
+ gene_start + tip_offset, # Tip (near gene start)
492
+ ]
493
+ arrow_color = "#333333" # Dark grey for reverse
494
+
495
+ for tip_x in arrow_tip_positions:
496
+ if arrow_dir == 1:
497
+ base_x = tip_x - tri_width
498
+ tri_points = [
499
+ [tip_x, y_gene], # Tip pointing right
500
+ [base_x, y_gene + tri_height],
501
+ [base_x, y_gene - tri_height],
502
+ ]
503
+ else:
504
+ base_x = tip_x + tri_width
505
+ tri_points = [
506
+ [tip_x, y_gene], # Tip pointing left
507
+ [base_x, y_gene + tri_height],
508
+ [base_x, y_gene - tri_height],
509
+ ]
510
+
511
+ backend.add_polygon(
512
+ ax,
513
+ tri_points,
514
+ facecolor=arrow_color,
515
+ edgecolor=arrow_color,
516
+ linewidth=0.5,
517
+ zorder=5,
518
+ )
519
+
520
+ # Add gene name label in the gap above gene
521
+ if gene_name:
522
+ label_pos = (gene_start + gene_end) / 2
523
+ backend.add_text(
524
+ ax,
525
+ label_pos,
526
+ y_label,
527
+ gene_name,
528
+ fontsize=6,
529
+ ha="center",
530
+ va="bottom",
531
+ color="#000000",
532
+ )