Skip to content

Commit 04f93a4

Browse files
committed
Add a new template parameter to ridgeplot()
Allow users to specify a Plotly figure template (by name, as a go.layout.Template object, or as a dict) directly through the new `template` parameter. When no explicit `colorscale` is specified, the default colorscale is now also inferred from the specified template.
1 parent 5924de4 commit 04f93a4

9 files changed

Lines changed: 210 additions & 8 deletions

File tree

src/ridgeplot/_color/colorscale.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
if TYPE_CHECKING:
1515
from collections.abc import Collection
1616

17+
import plotly.graph_objects as go
18+
1719
from ridgeplot._types import Color, ColorScale
1820

1921

@@ -43,19 +45,29 @@ def validate_coerce(self, v: Any) -> ColorScale:
4345
return cast("ColorScale", coerced)
4446

4547

46-
def infer_default_colorscale() -> ColorScale | Collection[Color] | str:
48+
def infer_default_colorscale(
49+
template: go.layout.Template | None = None,
50+
) -> ColorScale | Collection[Color] | str:
51+
if template is None:
52+
template = default_plotly_template()
4753
return validate_coerce_colorscale(
48-
default_plotly_template().layout.colorscale.sequential or px.colors.sequential.Viridis
54+
template.layout.colorscale.sequential or px.colors.sequential.Viridis
4955
)
5056

5157

5258
def validate_coerce_colorscale(
5359
colorscale: ColorScale | Collection[Color] | str | None,
60+
template: go.layout.Template | None = None,
5461
) -> ColorScale:
5562
"""Convert mixed colorscale representations to the canonical
56-
:data:`ColorScale` format."""
63+
:data:`ColorScale` format.
64+
65+
If ``colorscale`` is None, the default colorscale is inferred from the
66+
given ``template`` (or from the current default Plotly template, if no
67+
template is specified).
68+
"""
5769
if colorscale is None:
58-
colorscale = infer_default_colorscale()
70+
colorscale = infer_default_colorscale(template=template)
5971
return ColorscaleValidator().validate_coerce(colorscale)
6072

6173

src/ridgeplot/_figure_factory.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from ridgeplot._obj.traces import get_trace_cls
1616
from ridgeplot._obj.traces.base import ColoringContext
17+
from ridgeplot._template import validate_coerce_template
1718
from ridgeplot._types import (
1819
Color,
1920
ColorScale,
@@ -37,7 +38,7 @@
3738
if TYPE_CHECKING:
3839
from collections.abc import Collection
3940

40-
from ridgeplot._types import Densities
41+
from ridgeplot._types import Densities, PlotlyTemplate
4142

4243

4344
def normalise_trace_types(
@@ -80,8 +81,11 @@ def _update_layout(
8081
xpad: float,
8182
x_max: float,
8283
x_min: float,
84+
template: go.layout.Template | None,
8385
) -> go.Figure:
8486
"""Update figure's layout."""
87+
if template is not None:
88+
fig.update_layout(template=template)
8589
fig.update_layout(
8690
legend=dict(traceorder="normal"),
8791
)
@@ -130,6 +134,7 @@ def create_ridgeplot(
130134
line_width: float | None,
131135
spacing: float,
132136
xpad: float,
137+
template: PlotlyTemplate | None,
133138
) -> go.Figure:
134139
# ==============================================================
135140
# --- Get clean and validated input arguments
@@ -169,7 +174,8 @@ def create_ridgeplot(
169174
line_width = float(line_width) if line_width is not None else None
170175
spacing = float(spacing)
171176
xpad = float(xpad)
172-
colorscale = validate_coerce_colorscale(colorscale)
177+
template = validate_coerce_template(template)
178+
colorscale = validate_coerce_colorscale(colorscale, template=template)
173179

174180
# ==============================================================
175181
# --- Build the figure
@@ -233,5 +239,6 @@ def create_ridgeplot(
233239
xpad=xpad,
234240
x_max=x_max,
235241
x_min=x_min,
242+
template=template,
236243
)
237244
return fig

src/ridgeplot/_ridgeplot.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
ColorScale,
3232
LabelsArray,
3333
NormalisationOption,
34+
PlotlyTemplate,
3435
SampleWeights,
3536
SampleWeightsArray,
3637
ShallowDensities,
@@ -120,6 +121,7 @@ def ridgeplot(
120121
line_width: float | None = None,
121122
spacing: float = 0.5,
122123
xpad: float = 0.05,
124+
template: PlotlyTemplate | None = None,
123125
# Deprecated parameters
124126
coloralpha: float | None | MissingType = MISSING,
125127
linewidth: float | MissingType = MISSING,
@@ -140,6 +142,8 @@ def ridgeplot(
140142
https://www.statsmodels.org/stable/_modules/statsmodels/nonparametric/bandwidths.html
141143
.. _Plotly's built-in color-scales:
142144
https://plotly.com/python/builtin-colorscales/
145+
.. _Plotly's theming and templates guide:
146+
https://plotly.com/python/templates/
143147
.. _ragged:
144148
https://en.wikipedia.org/wiki/Jagged_array
145149
@@ -290,8 +294,8 @@ def ridgeplot(
290294
``["rgb(255, 0, 0)", "blue", "hsl(120, 100%, 50%)"]``). The list will
291295
ultimately be converted into a :data:`~ridgeplot._types.ColorScale`
292296
object, assuming the colors provided are evenly spaced. If not specified
293-
(default), the color scale will be inferred from current Plotly
294-
template.
297+
(default), the color scale will be inferred from the current Plotly
298+
template (see the :paramref:`.template` parameter).
295299
296300
colormode : "fillgradient" or SolidColormode
297301
This parameter controls the logic used for the coloring of each
@@ -395,6 +399,20 @@ def ridgeplot(
395399
units of the range between the minimum and maximum x-values from all
396400
distributions.
397401
402+
template : PlotlyTemplate or None
403+
The Plotly template to use in this figure. This can be the name of a
404+
registered template (e.g., ``"plotly_dark"``), a
405+
:class:`plotly.graph_objects.layout.Template
406+
<plotly.graph_objs.layout.Template>` object, or a dictionary with a
407+
template's properties. See `Plotly's theming and templates guide`_
408+
for more details. If not specified (default), the plot will be
409+
rendered using Plotly's current default template (i.e.,
410+
``plotly.io.templates.default``). Note that, unless a custom
411+
:paramref:`.colorscale` is specified, the default colorscale will
412+
also be inferred from this template.
413+
414+
.. versionadded:: 0.7.0
415+
398416
coloralpha : float
399417
400418
.. deprecated:: 0.2.0
@@ -511,5 +529,6 @@ def ridgeplot(
511529
line_width=line_width,
512530
spacing=spacing,
513531
xpad=xpad,
532+
template=template,
514533
)
515534
return fig

src/ridgeplot/_template.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Plotly figure template utilities."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, cast
6+
7+
from _plotly_utils.basevalidators import BaseTemplateValidator
8+
9+
if TYPE_CHECKING:
10+
import plotly.graph_objects as go
11+
12+
from ridgeplot._types import PlotlyTemplate
13+
14+
15+
def validate_coerce_template(template: PlotlyTemplate | None) -> go.layout.Template | None:
16+
"""Convert mixed template representations into a
17+
:class:`plotly.graph_objects.layout.Template
18+
<plotly.graph_objs.layout.Template>` object.
19+
20+
``None`` is passed through as-is, meaning that no template has been
21+
specified and that Plotly's current default template should be used.
22+
"""
23+
if template is None:
24+
return None
25+
validator = BaseTemplateValidator(
26+
plotly_name="template",
27+
parent_name="layout",
28+
data_class_str="Template",
29+
data_docs="",
30+
)
31+
return cast("go.layout.Template", validator.validate_coerce(template))

src/ridgeplot/_types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Literal, TypeAlias
88

99
import numpy as np
10+
import plotly.graph_objects as go
1011
from typing_extensions import Any, TypeIs, TypeVar
1112

1213
# Snippet used to generate and store the image artifacts:
@@ -70,6 +71,16 @@
7071
available for the ridgeplot. See :paramref:`ridgeplot.ridgeplot.norm` for more
7172
details."""
7273

74+
PlotlyTemplate: TypeAlias = go.layout.Template | dict[str, Any] | str
75+
"""A Plotly figure template can be represented by a
76+
:class:`plotly.graph_objects.layout.Template <plotly.graph_objs.layout.Template>`
77+
object, a dictionary with a template's properties, or the name of a registered
78+
template (e.g., ``"plotly_dark"``).
79+
80+
See `Plotly's theming and templates guide
81+
<https://plotly.com/python/templates/>`_ for more details.
82+
"""
83+
7384
# ========================================================
7485
# --- Base nested Collection types (ragged arrays)
7586
# ========================================================

tests/unit/color/test_colorscale.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import re
44
from typing import TYPE_CHECKING
55

6+
import plotly.express as px
7+
import plotly.graph_objects as go
8+
import plotly.io as pio
69
import pytest
710

811
from ridgeplot._color.colorscale import (
@@ -26,6 +29,20 @@ def test_infer_default_colorscale() -> None:
2629
assert infer_default_colorscale() == validate_coerce_colorscale("plasma")
2730

2831

32+
def test_infer_default_colorscale_from_template() -> None:
33+
template = pio.templates["ggplot2"]
34+
assert infer_default_colorscale(template=template) == validate_coerce_colorscale(
35+
template.layout.colorscale.sequential
36+
)
37+
38+
39+
def test_infer_default_colorscale_fallback_to_viridis() -> None:
40+
template = go.layout.Template()
41+
assert infer_default_colorscale(template=template) == validate_coerce_colorscale(
42+
px.colors.sequential.Viridis
43+
)
44+
45+
2946
# ==============================================================
3047
# --- validate_coerce_colorscale()
3148
# ==============================================================

tests/unit/test_figure_factory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,5 @@ def test_densities_must_be_4d(self, densities: Densities) -> None:
134134
line_width=..., # type: ignore[reportArgumentType]
135135
spacing=..., # type: ignore[reportArgumentType]
136136
xpad=..., # type: ignore[reportArgumentType]
137+
template=..., # type: ignore[reportArgumentType]
137138
)

tests/unit/test_ridgeplot.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any
55

66
import plotly.express as px
7+
import plotly.io as pio
78
import pytest
89

910
from ridgeplot import ridgeplot
@@ -171,6 +172,65 @@ def test_colorscale_invalid(invalid_colorscale: ColorScale | Collection[Color] |
171172
ridgeplot(samples=[[[1, 2, 3], [4, 5, 6]]], colorscale=invalid_colorscale)
172173

173174

175+
# ==============================================================
176+
# --- param: template
177+
# ==============================================================
178+
179+
180+
def test_template_is_applied_to_the_figure() -> None:
181+
fig = ridgeplot(samples=[[[1, 2, 3], [4, 5, 6]]], template="plotly_dark")
182+
assert fig.layout.template == pio.templates["plotly_dark"]
183+
184+
185+
def test_template_equivalent_representations() -> None:
186+
samples = [[[1, 2, 3], [4, 5, 6]]]
187+
template = pio.templates["seaborn"]
188+
assert (
189+
ridgeplot(samples=samples, template="seaborn") ==
190+
ridgeplot(samples=samples, template=template) ==
191+
ridgeplot(samples=samples, template=template.to_plotly_json())
192+
) # fmt: skip
193+
194+
195+
def test_template_default() -> None:
196+
# Not specifying a template should be equivalent to
197+
# specifying Plotly's current default template
198+
samples = [[[1, 2, 3], [4, 5, 6]]]
199+
assert (
200+
ridgeplot(samples=samples) ==
201+
ridgeplot(samples=samples, template=pio.templates.default or "plotly")
202+
) # fmt: skip
203+
204+
205+
def test_template_sets_default_colorscale() -> None:
206+
# The default colorscale should be inferred from the
207+
# template specified via the `template` argument
208+
samples = [[[1, 2, 3], [4, 5, 6]]]
209+
template = pio.templates["ggplot2"]
210+
assert (
211+
ridgeplot(samples=samples, template="ggplot2") ==
212+
ridgeplot(
213+
samples=samples,
214+
template="ggplot2",
215+
colorscale=template.layout.colorscale.sequential,
216+
)
217+
) # fmt: skip
218+
219+
220+
def test_template_explicit_colorscale_takes_precedence() -> None:
221+
fig = ridgeplot(
222+
samples=[[[1, 2, 3], [4, 5, 6]]],
223+
template="ggplot2",
224+
colorscale=(
225+
(0.0, "rgb(10, 10, 10)"),
226+
(1.0, "rgb(20, 20, 20)"),
227+
),
228+
colormode="trace-index",
229+
)
230+
assert fig.data[1].fillcolor == "rgb(20, 20, 20)"
231+
assert fig.data[3].fillcolor == "rgb(10, 10, 10)"
232+
233+
174234
# ==============================================================
175235
# --- param: color_discrete_map
176236
# ==============================================================

tests/unit/test_template.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import plotly.graph_objects as go
4+
import plotly.io as pio
5+
import pytest
6+
7+
from ridgeplot._template import validate_coerce_template
8+
9+
10+
def test_none_passthrough() -> None:
11+
assert validate_coerce_template(None) is None
12+
13+
14+
def test_registered_template_name() -> None:
15+
assert validate_coerce_template("plotly_dark") == pio.templates["plotly_dark"]
16+
17+
18+
# Merging templates causes Plotly to internally instantiate all
19+
# registered trace types, including the deprecated Scattermapbox
20+
@pytest.mark.filterwarnings(r"ignore:\*scattermapbox\* is deprecated.*:DeprecationWarning")
21+
def test_merged_template_names() -> None:
22+
assert validate_coerce_template("plotly_white+ggplot2") == pio.templates["plotly_white+ggplot2"]
23+
24+
25+
def test_template_object() -> None:
26+
template = pio.templates["seaborn"]
27+
assert validate_coerce_template(template) == template
28+
29+
30+
def test_template_dict() -> None:
31+
template = pio.templates["seaborn"]
32+
coerced = validate_coerce_template(template.to_plotly_json())
33+
assert isinstance(coerced, go.layout.Template)
34+
assert coerced == template
35+
36+
37+
def test_invalid_template_name() -> None:
38+
with pytest.raises(ValueError, match=r"Invalid value .* received for the 'template' property"):
39+
validate_coerce_template("this-is-not-a-registered-template")
40+
41+
42+
def test_invalid_template_type() -> None:
43+
with pytest.raises(ValueError, match=r"Invalid value .* received for the 'template' property"):
44+
validate_coerce_template(42) # pyright: ignore[reportArgumentType]

0 commit comments

Comments
 (0)