cplots 0.0.1__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.
- cplots/__init__.py +85 -0
- cplots/backends/__init__.py +4 -0
- cplots/backends/_base.py +36 -0
- cplots/backends/altair.py +296 -0
- cplots/backends/matplotlib.py +233 -0
- cplots/backends/plotly.py +404 -0
- cplots/backends/ultraplot.py +84 -0
- cplots/config.py +29 -0
- cplots/core/__init__.py +43 -0
- cplots/core/base.py +96 -0
- cplots/core/data.py +49 -0
- cplots/core/incremental.py +182 -0
- cplots/core/labels.py +66 -0
- cplots/core/protocols.py +79 -0
- cplots/core/registry.py +86 -0
- cplots/core/spec.py +129 -0
- cplots/core/theme.py +59 -0
- cplots/data/__init__.py +3 -0
- cplots/data/_getdist.py +26 -0
- cplots/data/_pandas.py +29 -0
- cplots/data/_polars.py +29 -0
- cplots/data/_xarray.py +29 -0
- cplots/data/adapters.py +33 -0
- cplots/figure.py +213 -0
- cplots/plots/__init__.py +17 -0
- cplots/plots/background.py +78 -0
- cplots/plots/power_spectrum.py +113 -0
- cplots/plots/triangle.py +85 -0
- cplots/plots/xy.py +97 -0
- cplots/plots/xyz_colored.py +126 -0
- cplots/plots/xyz_colored_grid.py +142 -0
- cplots/py.typed +0 -0
- cplots-0.0.1.dist-info/METADATA +148 -0
- cplots-0.0.1.dist-info/RECORD +36 -0
- cplots-0.0.1.dist-info/WHEEL +4 -0
- cplots-0.0.1.dist-info/entry_points.txt +6 -0
cplots/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""cplots: a backend-agnostic cosmological plotting library.
|
|
2
|
+
|
|
3
|
+
Three-layer architecture:
|
|
4
|
+
PlotData (immutable input) → PlotSpec IR → Backend (renders native figure)
|
|
5
|
+
|
|
6
|
+
Backends (matplotlib, plotly, altair) are selected per call, via context
|
|
7
|
+
manager, or globally. Third-party backends register via entry points.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> import numpy as np
|
|
11
|
+
>>> import cplots
|
|
12
|
+
>>> k = np.logspace(-3, 0, 200)
|
|
13
|
+
>>> pk = k ** -1.5
|
|
14
|
+
>>> cplots.PKSpectrum(k, pk, label="P(k)").show()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Trigger backend registration by importing the backends package.
|
|
18
|
+
import cplots.backends # noqa: E402, F401
|
|
19
|
+
from cplots.config import backend, resolve_backend, set_backend
|
|
20
|
+
from cplots.core.base import Plot
|
|
21
|
+
from cplots.core.data import (
|
|
22
|
+
BackgroundData,
|
|
23
|
+
ClData,
|
|
24
|
+
PKData,
|
|
25
|
+
PlotData,
|
|
26
|
+
TriangleData,
|
|
27
|
+
XYData,
|
|
28
|
+
XYZData,
|
|
29
|
+
)
|
|
30
|
+
from cplots.core.incremental import (
|
|
31
|
+
build_incrementally,
|
|
32
|
+
incremental,
|
|
33
|
+
labeled_prefixes,
|
|
34
|
+
sequence_prefixes,
|
|
35
|
+
)
|
|
36
|
+
from cplots.core.protocols import BackendProtocol
|
|
37
|
+
from cplots.core.registry import get_backend, register_backend
|
|
38
|
+
from cplots.core.spec import FigureSpec, PanelSpec, PlotSpec, Trace
|
|
39
|
+
from cplots.core.theme import DARK, PUBLICATION, ThemeSpec
|
|
40
|
+
from cplots.data.adapters import to_plot_data
|
|
41
|
+
from cplots.figure import Figure
|
|
42
|
+
from cplots.plots.background import BackgroundEvolution
|
|
43
|
+
from cplots.plots.power_spectrum import ClSpectrum, PKSpectrum, PowerSpectrumBase
|
|
44
|
+
from cplots.plots.triangle import TrianglePlot
|
|
45
|
+
from cplots.plots.xy import XYPlot
|
|
46
|
+
from cplots.plots.xyz_colored import XYZColored
|
|
47
|
+
from cplots.plots.xyz_colored_grid import XYZColoredGrid
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"DARK",
|
|
51
|
+
"Figure",
|
|
52
|
+
"FigureSpec",
|
|
53
|
+
"PanelSpec",
|
|
54
|
+
"PUBLICATION",
|
|
55
|
+
"BackendProtocol",
|
|
56
|
+
"BackgroundData",
|
|
57
|
+
"BackgroundEvolution",
|
|
58
|
+
"ClData",
|
|
59
|
+
"ClSpectrum",
|
|
60
|
+
"PKData",
|
|
61
|
+
"PKSpectrum",
|
|
62
|
+
"Plot",
|
|
63
|
+
"PlotData",
|
|
64
|
+
"PlotSpec",
|
|
65
|
+
"PowerSpectrumBase",
|
|
66
|
+
"ThemeSpec",
|
|
67
|
+
"Trace",
|
|
68
|
+
"TriangleData",
|
|
69
|
+
"TrianglePlot",
|
|
70
|
+
"XYData",
|
|
71
|
+
"XYPlot",
|
|
72
|
+
"XYZColored",
|
|
73
|
+
"XYZColoredGrid",
|
|
74
|
+
"XYZData",
|
|
75
|
+
"backend",
|
|
76
|
+
"build_incrementally",
|
|
77
|
+
"get_backend",
|
|
78
|
+
"incremental",
|
|
79
|
+
"labeled_prefixes",
|
|
80
|
+
"register_backend",
|
|
81
|
+
"resolve_backend",
|
|
82
|
+
"sequence_prefixes",
|
|
83
|
+
"set_backend",
|
|
84
|
+
"to_plot_data",
|
|
85
|
+
]
|
cplots/backends/_base.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseBackend:
|
|
7
|
+
"""Optional base class for backends that want automatic registration.
|
|
8
|
+
|
|
9
|
+
Subclass with the ``name`` keyword to self-register on class creation::
|
|
10
|
+
|
|
11
|
+
class MyBackend(BaseBackend, name="mybackend"):
|
|
12
|
+
def render(self, spec): ...
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
The ``name`` keyword triggers :func:`~cplots.core.registry.register_backend`
|
|
16
|
+
at class-definition time so no manual registration call is required.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init_subclass__(cls, name: str | None = None, **kw: Any) -> None:
|
|
20
|
+
super().__init_subclass__(**kw)
|
|
21
|
+
if name is not None:
|
|
22
|
+
from cplots.core.registry import register_backend
|
|
23
|
+
|
|
24
|
+
register_backend(name, cls())
|
|
25
|
+
|
|
26
|
+
def render_figure(self, spec: Any) -> Any:
|
|
27
|
+
"""Render a multi-panel FigureSpec.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
NotImplementedError: Subclasses that support multi-panel layouts
|
|
31
|
+
must override this method.
|
|
32
|
+
"""
|
|
33
|
+
raise NotImplementedError(
|
|
34
|
+
f"{type(self).__name__} does not implement render_figure(). "
|
|
35
|
+
"Multi-panel layouts require a backend that supports FigureSpec."
|
|
36
|
+
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from cplots.backends._base import BaseBackend
|
|
7
|
+
from cplots.core.labels import strip_latex
|
|
8
|
+
from cplots.core.spec import FigureSpec, PlotSpec
|
|
9
|
+
from cplots.core.theme import ThemeSpec
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import altair as alt
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _css_color(color: str) -> str:
|
|
16
|
+
"""Normalise matplotlib's 'none' to a CSS-compatible transparent value."""
|
|
17
|
+
return "transparent" if color == "none" else color
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AltairBackend(BaseBackend, name="altair"):
|
|
21
|
+
"""Altair (Vega-Lite) backend for cplots.
|
|
22
|
+
|
|
23
|
+
Produces ``altair.Chart`` objects. Registered automatically under the name
|
|
24
|
+
``"altair"``.
|
|
25
|
+
|
|
26
|
+
Note:
|
|
27
|
+
Rowspan > 1 in multi-panel layouts is not supported; use the matplotlib
|
|
28
|
+
or plotly backend for spanning layouts.
|
|
29
|
+
|
|
30
|
+
Requires: ``pip install cplots[altair]``
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _fmt(text: str, spec: PlotSpec) -> str:
|
|
35
|
+
return text if spec.latex_labels else strip_latex(text)
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
try:
|
|
39
|
+
import altair # noqa: F401
|
|
40
|
+
except ImportError as exc:
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"The altair backend requires altair. "
|
|
43
|
+
"Install it with: pip install cplots[altair]"
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
def render(self, spec: PlotSpec, *, interactive: bool = True) -> alt.Chart:
|
|
47
|
+
"""Render a single PlotSpec to an Altair Chart.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
spec: Backend-agnostic plot specification.
|
|
51
|
+
interactive: Whether to call ``.interactive()`` on the chart,
|
|
52
|
+
enabling pan and zoom in Jupyter notebooks.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An ``altair.Chart`` (or layered chart for multi-trace plots).
|
|
56
|
+
"""
|
|
57
|
+
import altair as alt
|
|
58
|
+
import pandas as pd
|
|
59
|
+
|
|
60
|
+
x_label = self._fmt(spec.x_label, spec)
|
|
61
|
+
y_label = self._fmt(spec.y_label, spec)
|
|
62
|
+
title = self._fmt(spec.title, spec)
|
|
63
|
+
|
|
64
|
+
if spec.colormap is not None:
|
|
65
|
+
chart = self._render_colormap(spec, x_label, y_label)
|
|
66
|
+
else:
|
|
67
|
+
layers = []
|
|
68
|
+
colors = spec.theme.palette if spec.theme else None
|
|
69
|
+
for i, trace in enumerate(spec.traces):
|
|
70
|
+
df = pd.DataFrame({"x": trace.x, "y": trace.y})
|
|
71
|
+
color = colors[i % len(colors)] if colors else alt.Undefined
|
|
72
|
+
label = self._fmt(trace.label, spec) if trace.label else None
|
|
73
|
+
layer = (
|
|
74
|
+
alt.Chart(df)
|
|
75
|
+
.mark_line()
|
|
76
|
+
.encode(
|
|
77
|
+
x=alt.X("x", scale=alt.Scale(type=spec.x_scale), title=x_label),
|
|
78
|
+
y=alt.Y("y", scale=alt.Scale(type=spec.y_scale), title=y_label),
|
|
79
|
+
color=alt.value(color) if isinstance(color, str) else alt.Undefined,
|
|
80
|
+
tooltip=["x", "y"],
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
if label:
|
|
84
|
+
layer = layer.properties(title=label)
|
|
85
|
+
layers.append(layer)
|
|
86
|
+
chart = alt.layer(*layers)
|
|
87
|
+
|
|
88
|
+
props: dict[str, Any] = {}
|
|
89
|
+
if title:
|
|
90
|
+
props["title"] = title
|
|
91
|
+
if spec.figsize:
|
|
92
|
+
props["width"] = int(spec.figsize[0] * 96)
|
|
93
|
+
props["height"] = int(spec.figsize[1] * 96)
|
|
94
|
+
if props:
|
|
95
|
+
chart = chart.properties(**props)
|
|
96
|
+
if interactive:
|
|
97
|
+
chart = chart.interactive()
|
|
98
|
+
return chart
|
|
99
|
+
|
|
100
|
+
def _render_colormap(
|
|
101
|
+
self,
|
|
102
|
+
spec: PlotSpec,
|
|
103
|
+
x_label: str,
|
|
104
|
+
y_label: str,
|
|
105
|
+
*,
|
|
106
|
+
color_domain: list[float] | None = None,
|
|
107
|
+
show_legend: bool = True,
|
|
108
|
+
gradient_length: int | None = None,
|
|
109
|
+
) -> Any:
|
|
110
|
+
import altair as alt
|
|
111
|
+
import pandas as pd
|
|
112
|
+
|
|
113
|
+
frames = []
|
|
114
|
+
for trace in spec.traces:
|
|
115
|
+
df = pd.DataFrame({
|
|
116
|
+
"x": trace.x,
|
|
117
|
+
"y": trace.y,
|
|
118
|
+
"z": trace.color_value,
|
|
119
|
+
"label": self._fmt(trace.label, spec) if trace.label else "",
|
|
120
|
+
})
|
|
121
|
+
frames.append(df)
|
|
122
|
+
data = pd.concat(frames, ignore_index=True)
|
|
123
|
+
|
|
124
|
+
chart_height = gradient_length or (int(spec.figsize[1] * 96) if spec.figsize else 300)
|
|
125
|
+
legend = alt.Legend(
|
|
126
|
+
title=self._fmt(spec.z_label, spec) if spec.z_label else None,
|
|
127
|
+
gradientLength=chart_height,
|
|
128
|
+
titleOrient="bottom",
|
|
129
|
+
)
|
|
130
|
+
scale_kwargs: dict[str, Any] = {"scheme": spec.colormap}
|
|
131
|
+
if color_domain is not None:
|
|
132
|
+
scale_kwargs["domain"] = color_domain
|
|
133
|
+
color_enc = alt.Color(
|
|
134
|
+
"z:Q",
|
|
135
|
+
scale=alt.Scale(**scale_kwargs),
|
|
136
|
+
legend=legend if (spec.colorbar and show_legend) else None,
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
alt.Chart(data)
|
|
140
|
+
.mark_line()
|
|
141
|
+
.encode(
|
|
142
|
+
x=alt.X("x:Q", scale=alt.Scale(type=spec.x_scale), title=x_label),
|
|
143
|
+
y=alt.Y("y:Q", scale=alt.Scale(type=spec.y_scale), title=y_label),
|
|
144
|
+
color=color_enc,
|
|
145
|
+
detail="label:N",
|
|
146
|
+
tooltip=["x:Q", "y:Q", "z:Q"],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _render_panel(
|
|
151
|
+
self,
|
|
152
|
+
spec: PlotSpec,
|
|
153
|
+
*,
|
|
154
|
+
interactive: bool = True,
|
|
155
|
+
panel_width: int | None = None,
|
|
156
|
+
panel_height: int | None = None,
|
|
157
|
+
) -> Any:
|
|
158
|
+
"""Render a single PlotSpec as an Altair chart.
|
|
159
|
+
|
|
160
|
+
``panel_width`` / ``panel_height`` override the chart size when the
|
|
161
|
+
caller (e.g. ``render_figure``) wants to distribute a shared figsize
|
|
162
|
+
across panels. Size must be set on each individual chart rather than
|
|
163
|
+
on the combined ``hconcat``/``vconcat`` container, which does not
|
|
164
|
+
accept ``width``/``height`` properties.
|
|
165
|
+
"""
|
|
166
|
+
chart = self.render(spec, interactive=interactive)
|
|
167
|
+
props: dict[str, Any] = {}
|
|
168
|
+
if panel_width is not None:
|
|
169
|
+
props["width"] = panel_width
|
|
170
|
+
if panel_height is not None:
|
|
171
|
+
props["height"] = panel_height
|
|
172
|
+
if props:
|
|
173
|
+
chart = chart.properties(**props)
|
|
174
|
+
return chart
|
|
175
|
+
|
|
176
|
+
def render_figure(self, spec: FigureSpec) -> Any:
|
|
177
|
+
"""Render a multi-panel FigureSpec as an Altair hconcat/vconcat chart.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
spec: Backend-agnostic multi-panel figure specification.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
An ``altair.Chart``, ``altair.HConcatChart``, or
|
|
184
|
+
``altair.VConcatChart`` depending on layout.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
NotImplementedError: If any panel has ``rowspan > 1``.
|
|
188
|
+
"""
|
|
189
|
+
import altair as alt
|
|
190
|
+
|
|
191
|
+
if spec.theme is not None:
|
|
192
|
+
self.apply_theme(spec.theme)
|
|
193
|
+
|
|
194
|
+
for panel in spec.panels:
|
|
195
|
+
if panel.rowspan > 1:
|
|
196
|
+
raise NotImplementedError(
|
|
197
|
+
"The Altair backend does not support rowspan > 1. "
|
|
198
|
+
"Use the matplotlib or plotly backend for spanning layouts."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Distribute figsize across panels; size is set per-chart because
|
|
202
|
+
# HConcatChart / VConcatChart do not accept width/height properties.
|
|
203
|
+
panel_w = int(spec.figsize[0] * 96 / spec.ncols) if spec.figsize else None
|
|
204
|
+
panel_h = int(spec.figsize[1] * 96 / spec.nrows) if spec.figsize else None
|
|
205
|
+
|
|
206
|
+
if spec.colormap is not None:
|
|
207
|
+
z_vals = [
|
|
208
|
+
t.color_value
|
|
209
|
+
for p in spec.panels
|
|
210
|
+
for t in p.plot.traces
|
|
211
|
+
if t.color_value is not None
|
|
212
|
+
]
|
|
213
|
+
vmin, vmax = min(z_vals), max(z_vals)
|
|
214
|
+
domain = [vmin, vmax]
|
|
215
|
+
n_panels = len(spec.panels)
|
|
216
|
+
panel_counter = 0
|
|
217
|
+
|
|
218
|
+
rows: list[Any] = []
|
|
219
|
+
for r in range(spec.nrows):
|
|
220
|
+
row_panels = sorted(
|
|
221
|
+
[p for p in spec.panels if p.row == r], key=lambda p: p.col
|
|
222
|
+
)
|
|
223
|
+
charts: list[Any] = []
|
|
224
|
+
for panel in row_panels:
|
|
225
|
+
p_spec = panel.plot
|
|
226
|
+
x_label = self._fmt(p_spec.x_label, p_spec)
|
|
227
|
+
y_label = self._fmt(p_spec.y_label, p_spec)
|
|
228
|
+
is_last = panel_counter == n_panels - 1
|
|
229
|
+
chart = self._render_colormap(
|
|
230
|
+
p_spec,
|
|
231
|
+
x_label,
|
|
232
|
+
y_label,
|
|
233
|
+
color_domain=domain,
|
|
234
|
+
show_legend=is_last and spec.colorbar,
|
|
235
|
+
gradient_length=panel_h,
|
|
236
|
+
)
|
|
237
|
+
props: dict[str, Any] = {}
|
|
238
|
+
if panel_w is not None:
|
|
239
|
+
props["width"] = panel_w
|
|
240
|
+
if panel_h is not None:
|
|
241
|
+
props["height"] = panel_h
|
|
242
|
+
if props:
|
|
243
|
+
chart = chart.properties(**props)
|
|
244
|
+
charts.append(chart)
|
|
245
|
+
panel_counter += 1
|
|
246
|
+
rows.append(charts[0] if len(charts) == 1 else alt.hconcat(*charts))
|
|
247
|
+
|
|
248
|
+
return rows[0] if len(rows) == 1 else alt.vconcat(*rows)
|
|
249
|
+
|
|
250
|
+
rows_plain: list[Any] = []
|
|
251
|
+
for r in range(spec.nrows):
|
|
252
|
+
row_panels = sorted(
|
|
253
|
+
[p for p in spec.panels if p.row == r], key=lambda p: p.col
|
|
254
|
+
)
|
|
255
|
+
charts_plain = [
|
|
256
|
+
self._render_panel(p.plot, panel_width=panel_w, panel_height=panel_h)
|
|
257
|
+
for p in row_panels
|
|
258
|
+
]
|
|
259
|
+
rows_plain.append(
|
|
260
|
+
charts_plain[0] if len(charts_plain) == 1 else alt.hconcat(*charts_plain)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return rows_plain[0] if len(rows_plain) == 1 else alt.vconcat(*rows_plain)
|
|
264
|
+
|
|
265
|
+
def show(self, fig: alt.Chart, **kwargs: Any) -> None:
|
|
266
|
+
fig.show(**kwargs)
|
|
267
|
+
|
|
268
|
+
def save(
|
|
269
|
+
self,
|
|
270
|
+
fig: alt.Chart,
|
|
271
|
+
path: str | os.PathLike[str],
|
|
272
|
+
**kwargs: Any,
|
|
273
|
+
) -> None:
|
|
274
|
+
fig.save(str(path), **kwargs)
|
|
275
|
+
|
|
276
|
+
def apply_theme(self, theme: ThemeSpec) -> None:
|
|
277
|
+
"""Register and enable a custom Vega-Lite theme from a ThemeSpec.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
theme: Theme to apply. Sets background color, font family, and
|
|
281
|
+
axis label font sizes via the Vega-Lite theme config.
|
|
282
|
+
"""
|
|
283
|
+
import altair as alt
|
|
284
|
+
|
|
285
|
+
alt.themes.register(
|
|
286
|
+
"cplots_custom",
|
|
287
|
+
lambda: {
|
|
288
|
+
"config": {
|
|
289
|
+
"background": _css_color(theme.background_color),
|
|
290
|
+
"font": theme.font_family,
|
|
291
|
+
"axisX": {"labelFontSize": theme.font_size},
|
|
292
|
+
"axisY": {"labelFontSize": theme.font_size},
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
alt.themes.enable("cplots_custom")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from cplots.backends._base import BaseBackend
|
|
7
|
+
from cplots.core.spec import FigureSpec, PlotSpec
|
|
8
|
+
from cplots.core.theme import ThemeSpec
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import matplotlib.axes
|
|
12
|
+
import matplotlib.figure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MatplotlibBackend(BaseBackend, name="matplotlib"):
|
|
16
|
+
"""Matplotlib backend for cplots.
|
|
17
|
+
|
|
18
|
+
Produces ``matplotlib.figure.Figure`` objects. Registered automatically
|
|
19
|
+
under the name ``"matplotlib"``.
|
|
20
|
+
|
|
21
|
+
Requires: ``pip install cplots[matplotlib]``
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
try:
|
|
26
|
+
import matplotlib as mpl # noqa: F401
|
|
27
|
+
except ImportError as exc:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"The matplotlib backend requires matplotlib. "
|
|
30
|
+
"Install it with: pip install cplots[matplotlib]"
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
def _draw_panel(
|
|
34
|
+
self,
|
|
35
|
+
fig: matplotlib.figure.Figure,
|
|
36
|
+
ax: matplotlib.axes.Axes,
|
|
37
|
+
spec: PlotSpec,
|
|
38
|
+
theme: ThemeSpec | None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Draw a single PlotSpec onto an existing Axes (palette and colormap paths)."""
|
|
41
|
+
bg = theme.background_color if theme else "white"
|
|
42
|
+
ax.set_facecolor(bg)
|
|
43
|
+
if spec.colormap is not None:
|
|
44
|
+
self._render_colormap(fig, ax, spec)
|
|
45
|
+
else:
|
|
46
|
+
colors = theme.palette if theme else None
|
|
47
|
+
for i, trace in enumerate(spec.traces):
|
|
48
|
+
color = colors[i % len(colors)] if colors else None
|
|
49
|
+
ax.plot(trace.x, trace.y, label=trace.label or None, color=color, **trace.style)
|
|
50
|
+
if any(t.label for t in spec.traces):
|
|
51
|
+
ax.legend()
|
|
52
|
+
ax.set_xscale(spec.x_scale)
|
|
53
|
+
ax.set_yscale(spec.y_scale)
|
|
54
|
+
if spec.x_label:
|
|
55
|
+
ax.set_xlabel(spec.x_label)
|
|
56
|
+
if spec.y_label:
|
|
57
|
+
ax.set_ylabel(spec.y_label)
|
|
58
|
+
if spec.title:
|
|
59
|
+
ax.set_title(spec.title)
|
|
60
|
+
|
|
61
|
+
def _draw_colormap_lines(
|
|
62
|
+
self,
|
|
63
|
+
ax: matplotlib.axes.Axes,
|
|
64
|
+
spec: PlotSpec,
|
|
65
|
+
norm: Any,
|
|
66
|
+
cmap: Any,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Draw colormap traces using pre-computed global norm/cmap (no colorbar)."""
|
|
69
|
+
for trace in spec.traces:
|
|
70
|
+
color = cmap(norm(trace.color_value)) if trace.color_value is not None else None
|
|
71
|
+
ax.plot(trace.x, trace.y, label=trace.label or None, color=color, **trace.style)
|
|
72
|
+
ax.set_xscale(spec.x_scale)
|
|
73
|
+
ax.set_yscale(spec.y_scale)
|
|
74
|
+
if spec.x_label:
|
|
75
|
+
ax.set_xlabel(spec.x_label)
|
|
76
|
+
if spec.y_label:
|
|
77
|
+
ax.set_ylabel(spec.y_label)
|
|
78
|
+
if spec.title:
|
|
79
|
+
ax.set_title(spec.title)
|
|
80
|
+
|
|
81
|
+
def render(self, spec: PlotSpec) -> matplotlib.figure.Figure:
|
|
82
|
+
"""Render a single PlotSpec to a matplotlib Figure.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
spec: Backend-agnostic plot specification.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A closed ``matplotlib.figure.Figure`` (call ``show()`` to display).
|
|
89
|
+
"""
|
|
90
|
+
import matplotlib.pyplot as plt
|
|
91
|
+
|
|
92
|
+
# Apply theme to rcParams before creating the figure so that newly
|
|
93
|
+
# created Text/Tick/Axis objects inherit the right defaults.
|
|
94
|
+
if spec.theme is not None:
|
|
95
|
+
self.apply_theme(spec.theme)
|
|
96
|
+
|
|
97
|
+
fig, ax = plt.subplots(figsize=spec.figsize)
|
|
98
|
+
|
|
99
|
+
# Set backgrounds directly on the objects — rcParams alone won't
|
|
100
|
+
# retroactively update a figure that has already been created.
|
|
101
|
+
bg = spec.theme.background_color if spec.theme else "white"
|
|
102
|
+
fig.patch.set_facecolor(bg)
|
|
103
|
+
self._draw_panel(fig, ax, spec, spec.theme)
|
|
104
|
+
|
|
105
|
+
plt.close(fig)
|
|
106
|
+
return fig
|
|
107
|
+
|
|
108
|
+
def _render_colormap(self, fig: Any, ax: Any, spec: PlotSpec) -> None:
|
|
109
|
+
import matplotlib.cm as cm
|
|
110
|
+
import matplotlib.colors as mcolors
|
|
111
|
+
import matplotlib.pyplot as plt
|
|
112
|
+
|
|
113
|
+
z_values = [t.color_value for t in spec.traces if t.color_value is not None]
|
|
114
|
+
vmin, vmax = min(z_values), max(z_values)
|
|
115
|
+
norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
116
|
+
cmap = plt.get_cmap(spec.colormap)
|
|
117
|
+
|
|
118
|
+
for trace in spec.traces:
|
|
119
|
+
color = cmap(norm(trace.color_value)) if trace.color_value is not None else None
|
|
120
|
+
ax.plot(trace.x, trace.y, label=trace.label or None, color=color, **trace.style)
|
|
121
|
+
|
|
122
|
+
if spec.colorbar:
|
|
123
|
+
sm = cm.ScalarMappable(norm=norm, cmap=cmap)
|
|
124
|
+
sm.set_array([])
|
|
125
|
+
pad = spec.colorbar_pad if spec.colorbar_pad is not None else 0.02
|
|
126
|
+
fig.colorbar(sm, ax=ax, label=spec.z_label or None, pad=pad)
|
|
127
|
+
|
|
128
|
+
def render_figure(self, spec: FigureSpec) -> matplotlib.figure.Figure:
|
|
129
|
+
"""Render a multi-panel FigureSpec to a matplotlib Figure.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
spec: Backend-agnostic multi-panel figure specification.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A closed ``matplotlib.figure.Figure``.
|
|
136
|
+
"""
|
|
137
|
+
import matplotlib.gridspec as mgridspec
|
|
138
|
+
import matplotlib.pyplot as plt
|
|
139
|
+
|
|
140
|
+
if spec.theme is not None:
|
|
141
|
+
self.apply_theme(spec.theme)
|
|
142
|
+
|
|
143
|
+
bg = spec.theme.background_color if spec.theme else "white"
|
|
144
|
+
fig = plt.figure(figsize=spec.figsize)
|
|
145
|
+
fig.patch.set_facecolor(bg)
|
|
146
|
+
|
|
147
|
+
gs = mgridspec.GridSpec(
|
|
148
|
+
spec.nrows,
|
|
149
|
+
spec.ncols,
|
|
150
|
+
width_ratios=spec.width_ratios,
|
|
151
|
+
height_ratios=spec.height_ratios,
|
|
152
|
+
figure=fig,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Pre-compute global colormap normalization for the shared colorbar path.
|
|
156
|
+
shared_norm: Any = None
|
|
157
|
+
shared_cmap: Any = None
|
|
158
|
+
if spec.colormap is not None:
|
|
159
|
+
import matplotlib.colors as mcolors
|
|
160
|
+
|
|
161
|
+
z_vals = [
|
|
162
|
+
t.color_value
|
|
163
|
+
for p in spec.panels
|
|
164
|
+
for t in p.plot.traces
|
|
165
|
+
if t.color_value is not None
|
|
166
|
+
]
|
|
167
|
+
vmin, vmax = min(z_vals), max(z_vals)
|
|
168
|
+
shared_norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
169
|
+
shared_cmap = plt.get_cmap(spec.colormap)
|
|
170
|
+
|
|
171
|
+
col_x_master: dict[int, matplotlib.axes.Axes] = {}
|
|
172
|
+
row_y_master: dict[int, matplotlib.axes.Axes] = {}
|
|
173
|
+
all_axes: list[matplotlib.axes.Axes] = []
|
|
174
|
+
|
|
175
|
+
for panel in spec.panels:
|
|
176
|
+
r, c, rs, cs = panel.row, panel.col, panel.rowspan, panel.colspan
|
|
177
|
+
share_x = col_x_master.get(c) if spec.sharex else None
|
|
178
|
+
share_y = row_y_master.get(r) if spec.sharey else None
|
|
179
|
+
ax = fig.add_subplot(gs[r : r + rs, c : c + cs], sharex=share_x, sharey=share_y)
|
|
180
|
+
col_x_master.setdefault(c, ax)
|
|
181
|
+
row_y_master.setdefault(r, ax)
|
|
182
|
+
ax.set_facecolor(bg)
|
|
183
|
+
all_axes.append(ax)
|
|
184
|
+
|
|
185
|
+
if shared_norm is not None:
|
|
186
|
+
self._draw_colormap_lines(ax, panel.plot, shared_norm, shared_cmap)
|
|
187
|
+
else:
|
|
188
|
+
self._draw_panel(fig, ax, panel.plot, spec.theme)
|
|
189
|
+
|
|
190
|
+
if shared_norm is not None and spec.colorbar:
|
|
191
|
+
import matplotlib.cm as cm
|
|
192
|
+
|
|
193
|
+
sm = cm.ScalarMappable(norm=shared_norm, cmap=shared_cmap)
|
|
194
|
+
sm.set_array([])
|
|
195
|
+
pad = spec.colorbar_pad if spec.colorbar_pad is not None else 0.02
|
|
196
|
+
fig.colorbar(sm, ax=all_axes, label=spec.z_label or None, pad=pad)
|
|
197
|
+
|
|
198
|
+
plt.close(fig)
|
|
199
|
+
return fig
|
|
200
|
+
|
|
201
|
+
def show(self, fig: matplotlib.figure.Figure, **kwargs: Any) -> None:
|
|
202
|
+
import matplotlib.pyplot as plt
|
|
203
|
+
|
|
204
|
+
plt.figure(fig.number)
|
|
205
|
+
plt.show(**kwargs)
|
|
206
|
+
|
|
207
|
+
def save(
|
|
208
|
+
self,
|
|
209
|
+
fig: matplotlib.figure.Figure,
|
|
210
|
+
path: str | os.PathLike[str],
|
|
211
|
+
**kwargs: Any,
|
|
212
|
+
) -> None:
|
|
213
|
+
fig.savefig(path, **kwargs)
|
|
214
|
+
|
|
215
|
+
def apply_theme(self, theme: ThemeSpec) -> None:
|
|
216
|
+
"""Apply a ThemeSpec to matplotlib's global rcParams.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
theme: Theme to apply. ``theme.extra`` keys are applied directly
|
|
220
|
+
to ``mpl.rcParams``. rcParams are reset to defaults first so
|
|
221
|
+
previous theme settings do not bleed into the current render.
|
|
222
|
+
"""
|
|
223
|
+
import matplotlib as mpl
|
|
224
|
+
|
|
225
|
+
# Reset to defaults first so that keys set by a previous theme
|
|
226
|
+
# (e.g. DARK's white tick/label colors) don't bleed into this render.
|
|
227
|
+
mpl.rcdefaults()
|
|
228
|
+
mpl.rcParams["font.family"] = theme.font_family
|
|
229
|
+
mpl.rcParams["font.size"] = theme.font_size
|
|
230
|
+
mpl.rcParams["figure.facecolor"] = theme.background_color
|
|
231
|
+
mpl.rcParams["axes.facecolor"] = theme.background_color
|
|
232
|
+
for key, value in theme.extra.items():
|
|
233
|
+
mpl.rcParams[key] = value
|