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.
pylocuszoom/__init__.py CHANGED
@@ -3,20 +3,21 @@
3
3
  This package provides LocusZoom-style regional association plots with:
4
4
  - LD coloring based on R² with lead variant
5
5
  - Gene and exon tracks
6
- - Recombination rate overlays (dog built-in, or user-provided)
6
+ - Recombination rate overlays (canine built-in, or user-provided)
7
7
  - Automatic SNP labeling
8
8
  - Multiple backends: matplotlib (static), plotly (interactive), bokeh (dashboards)
9
9
  - eQTL overlay support
10
+ - Fine-mapping/SuSiE visualization (PIP line with credible set coloring)
10
11
  - PySpark DataFrame support for large-scale data
11
12
 
12
13
  Example:
13
14
  >>> from pylocuszoom import LocusZoomPlotter
14
- >>> plotter = LocusZoomPlotter(species="dog")
15
+ >>> plotter = LocusZoomPlotter(species="canine")
15
16
  >>> fig = plotter.plot(gwas_df, chrom=1, start=1000000, end=2000000)
16
17
  >>> fig.savefig("regional_plot.png", dpi=150)
17
18
 
18
19
  Interactive example:
19
- >>> plotter = LocusZoomPlotter(species="dog", backend="plotly")
20
+ >>> plotter = LocusZoomPlotter(species="canine", backend="plotly")
20
21
  >>> fig = plotter.plot(gwas_df, chrom=1, start=1000000, end=2000000)
21
22
  >>> fig.write_html("regional_plot.html")
22
23
 
@@ -28,22 +29,42 @@ Stacked plots:
28
29
  ... )
29
30
 
30
31
  Species Support:
31
- - Dog (Canis lupus familiaris): Full features including built-in recombination maps
32
- - Cat (Felis catus): LD coloring and gene tracks (user provides recombination data)
32
+ - Canine (Canis lupus familiaris): Full features including built-in recombination maps
33
+ - Feline (Felis catus): LD coloring and gene tracks (user provides recombination data)
33
34
  - Custom: User provides all reference data
34
35
  """
35
36
 
36
37
  __version__ = "0.1.0"
37
38
 
38
39
  # Main plotter class
39
- from .plotter import LocusZoomPlotter
40
-
41
40
  # Backend types
42
41
  from .backends import BackendType, get_backend
43
42
 
44
43
  # Colors and LD
45
44
  from .colors import LEAD_SNP_COLOR, get_ld_bin, get_ld_color, get_ld_color_palette
46
45
 
46
+ # eQTL support
47
+ from .eqtl import (
48
+ EQTLValidationError,
49
+ calculate_colocalization_overlap,
50
+ filter_eqtl_by_gene,
51
+ filter_eqtl_by_region,
52
+ get_eqtl_genes,
53
+ prepare_eqtl_for_plotting,
54
+ validate_eqtl_df,
55
+ )
56
+
57
+ # Fine-mapping/SuSiE support
58
+ from .finemapping import (
59
+ FinemappingValidationError,
60
+ filter_by_credible_set,
61
+ filter_finemapping_by_region,
62
+ get_credible_sets,
63
+ get_top_pip_variants,
64
+ prepare_finemapping_for_plotting,
65
+ validate_finemapping_df,
66
+ )
67
+
47
68
  # Gene track
48
69
  from .gene_track import get_nearest_gene, plot_gene_track
49
70
 
@@ -55,26 +76,16 @@ from .ld import calculate_ld
55
76
 
56
77
  # Logging configuration
57
78
  from .logging import disable_logging, enable_logging
79
+ from .plotter import LocusZoomPlotter
58
80
 
59
81
  # Reference data management
60
82
  from .recombination import (
61
83
  add_recombination_overlay,
62
- download_dog_recombination_maps,
84
+ download_canine_recombination_maps,
63
85
  get_recombination_rate_for_region,
64
86
  load_recombination_map,
65
87
  )
66
88
 
67
- # eQTL support
68
- from .eqtl import (
69
- EQTLValidationError,
70
- calculate_colocalization_overlap,
71
- filter_eqtl_by_gene,
72
- filter_eqtl_by_region,
73
- get_eqtl_genes,
74
- prepare_eqtl_for_plotting,
75
- validate_eqtl_df,
76
- )
77
-
78
89
  # Validation utilities
79
90
  from .utils import ValidationError, to_pandas
80
91
 
@@ -86,7 +97,7 @@ __all__ = [
86
97
  "BackendType",
87
98
  "get_backend",
88
99
  # Reference data
89
- "download_dog_recombination_maps",
100
+ "download_canine_recombination_maps",
90
101
  # Colors
91
102
  "get_ld_color",
92
103
  "get_ld_bin",
@@ -111,6 +122,14 @@ __all__ = [
111
122
  "get_eqtl_genes",
112
123
  "calculate_colocalization_overlap",
113
124
  "EQTLValidationError",
125
+ # Fine-mapping/SuSiE
126
+ "validate_finemapping_df",
127
+ "filter_finemapping_by_region",
128
+ "filter_by_credible_set",
129
+ "get_credible_sets",
130
+ "get_top_pip_variants",
131
+ "prepare_finemapping_for_plotting",
132
+ "FinemappingValidationError",
114
133
  # Logging
115
134
  "enable_logging",
116
135
  "disable_logging",
@@ -3,15 +3,11 @@
3
3
  Supports matplotlib (default), plotly, and bokeh backends.
4
4
  """
5
5
 
6
- from typing import TYPE_CHECKING, Literal
6
+ from typing import Literal
7
7
 
8
8
  from .base import PlotBackend
9
9
  from .matplotlib_backend import MatplotlibBackend
10
10
 
11
- if TYPE_CHECKING:
12
- from .bokeh_backend import BokehBackend
13
- from .plotly_backend import PlotlyBackend
14
-
15
11
  BackendType = Literal["matplotlib", "plotly", "bokeh"]
16
12
 
17
13
  _BACKENDS: dict[str, type[PlotBackend]] = {
@@ -3,7 +3,7 @@
3
3
  Defines the interface that matplotlib, plotly, and bokeh backends must implement.
4
4
  """
5
5
 
6
- from typing import Any, Dict, List, Optional, Protocol, Tuple, Union
6
+ from typing import Any, List, Optional, Protocol, Tuple, Union
7
7
 
8
8
  import pandas as pd
9
9
 
@@ -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, Legend, LegendItem, 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
 
@@ -108,39 +104,44 @@ class BokehBackend:
108
104
 
109
105
  # Handle sizes (convert from area to diameter)
110
106
  if isinstance(sizes, (int, float)):
111
- bokeh_size = max(6, sizes ** 0.5)
107
+ bokeh_size = max(6, sizes**0.5)
112
108
  data["size"] = [bokeh_size] * len(x)
113
109
  else:
114
- data["size"] = [max(6, s ** 0.5) for s in sizes]
110
+ data["size"] = [max(6, s**0.5) for s in sizes]
115
111
 
116
112
  # Add hover data
117
113
  tooltips = []
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
@@ -289,7 +293,6 @@ class BokehBackend:
289
293
  zorder: int = 2,
290
294
  ) -> Any:
291
295
  """Add a rectangle to the figure."""
292
- from bokeh.models import Rect
293
296
 
294
297
  x_center = xy[0] + width / 2
295
298
  y_center = xy[1] + height / 2
@@ -304,6 +307,28 @@ class BokehBackend:
304
307
  line_width=linewidth,
305
308
  )
306
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
+
307
332
  def set_xlim(self, ax: figure, left: float, right: float) -> None:
308
333
  """Set x-axis limits."""
309
334
  ax.x_range.start = left
@@ -316,14 +341,31 @@ class BokehBackend:
316
341
 
317
342
  def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
318
343
  """Set x-axis label."""
344
+ label = self._convert_label(label)
319
345
  ax.xaxis.axis_label = label
320
346
  ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
321
347
 
322
348
  def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
323
349
  """Set y-axis label."""
350
+ label = self._convert_label(label)
324
351
  ax.yaxis.axis_label = label
325
352
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
326
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
+
327
369
  def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
328
370
  """Set figure title."""
329
371
  ax.title.text = title
@@ -342,6 +384,141 @@ class BokehBackend:
342
384
 
343
385
  return "secondary"
344
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
+
345
522
  def add_legend(
346
523
  self,
347
524
  ax: figure,
@@ -370,26 +547,18 @@ class BokehBackend:
370
547
  return ax.legend
371
548
 
372
549
  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
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
380
556
 
381
557
  def format_xaxis_mb(self, ax: figure) -> None:
382
558
  """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
559
+ from bokeh.models import CustomJSTickFormatter
391
560
 
392
- ax.xaxis.formatter = FuncTickFormatter(
561
+ ax.xaxis.formatter = CustomJSTickFormatter(
393
562
  code="return (tick / 1e6).toFixed(2);"
394
563
  )
395
564
 
@@ -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)
@@ -205,7 +227,12 @@ class MatplotlibBackend:
205
227
 
206
228
  def set_title(self, ax: Axes, title: str, fontsize: int = 14) -> None:
207
229
  """Set panel title."""
208
- ax.set_title(title, fontsize=fontsize)
230
+ ax.set_title(
231
+ title,
232
+ fontsize=fontsize,
233
+ fontweight="bold",
234
+ fontfamily="sans-serif",
235
+ )
209
236
 
210
237
  def create_twin_axis(self, ax: Axes) -> Axes:
211
238
  """Create a secondary y-axis sharing the same x-axis."""