trendify2 2.0.4__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.
- trendify/__about__.py +14 -0
- trendify/__init__.py +250 -0
- trendify/base/__init__.py +36 -0
- trendify/base/helpers.py +76 -0
- trendify/base/pen.py +161 -0
- trendify/base/record.py +148 -0
- trendify/cli.py +403 -0
- trendify/color.py +260 -0
- trendify/examples.py +474 -0
- trendify/formats/__init__.py +28 -0
- trendify/formats/format2d.py +128 -0
- trendify/formats/table.py +55 -0
- trendify/generator/__init__.py +38 -0
- trendify/generator/generate.py +167 -0
- trendify/generator/histogrammer.py +62 -0
- trendify/generator/render.py +195 -0
- trendify/generator/table_builder.py +194 -0
- trendify/generator/xy_data_plotter.py +195 -0
- trendify/log.py +195 -0
- trendify/pipeline.py +136 -0
- trendify/plotting/__init__.py +48 -0
- trendify/plotting/axline.py +122 -0
- trendify/plotting/figure.py +389 -0
- trendify/plotting/histogram.py +282 -0
- trendify/plotting/point.py +89 -0
- trendify/plotting/scatter.py +100 -0
- trendify/plotting/trace.py +129 -0
- trendify/progress.py +43 -0
- trendify/store/__init__.py +30 -0
- trendify/store/db.py +119 -0
- trendify/store/record_store.py +448 -0
- trendify/store/tags.py +65 -0
- trendify/styling/__init__.py +30 -0
- trendify/styling/grid.py +125 -0
- trendify/styling/legend.py +217 -0
- trendify/styling/marker.py +168 -0
- trendify/typing.py +26 -0
- trendify/viewer/__init__.py +41 -0
- trendify/viewer/app.py +208 -0
- trendify/viewer/hydration.py +181 -0
- trendify/viewer/plot_config.py +102 -0
- trendify/viewer/routes/__init__.py +6 -0
- trendify/viewer/routes/api.py +360 -0
- trendify/viewer/routes/pages.py +39 -0
- trendify/viewer/tag_tree.py +148 -0
- trendify/viewer/templates/base.html +63 -0
- trendify/viewer/templates/index.html +131 -0
- trendify/viewer/templates/partials/__init__.py +1 -0
- trendify/viewer/templates/partials/footer.html +15 -0
- trendify/viewer/templates/partials/nav.html +181 -0
- trendify/viewer/templates/partials/plot_view.html +221 -0
- trendify/viewer/templates/partials/sidebar.html +163 -0
- trendify/viewer/templates/partials/table_view.html +43 -0
- trendify/viewer/templates/static/__init__.py +1 -0
- trendify/viewer/templates/static/assets/__init__.py +1 -0
- trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
- trendify/viewer/templates/static/assets/main-logo.svg +66 -0
- trendify/viewer/templates/static/css/app.css +75 -0
- trendify/viewer/templates/static/js/main.js +2 -0
- trendify/viewer/templates/static/vendored/__init__.py +1 -0
- trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
- trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
- trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
- trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
- trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
- trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
- trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
- trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
- trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
- trendify2-2.0.4.dist-info/METADATA +107 -0
- trendify2-2.0.4.dist-info/RECORD +76 -0
- trendify2-2.0.4.dist-info/WHEEL +4 -0
- trendify2-2.0.4.dist-info/entry_points.txt +2 -0
- trendify2-2.0.4.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`SingleAxisFigure` (matplotlib) and `PlotlyFigure` (Plotly) wrap a figure/axes pair and know how
|
|
3
|
+
to apply a `Format2D`/`Grid` to it and save it to disk. These are the two rendering targets
|
|
4
|
+
every `PlottableData2D` subclass draws itself onto (`plot_to_ax` for matplotlib, `add_to_plotly`
|
|
5
|
+
for Plotly).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Self, cast
|
|
15
|
+
|
|
16
|
+
import matplotlib
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
import numpy as np
|
|
19
|
+
import plotly.graph_objects as go
|
|
20
|
+
from pydantic import ConfigDict
|
|
21
|
+
|
|
22
|
+
from trendify.base.helpers import Tag
|
|
23
|
+
from trendify.formats.format2d import AxisScale, PlottableData2D
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from matplotlib.axes import Axes
|
|
27
|
+
from matplotlib.figure import Figure
|
|
28
|
+
|
|
29
|
+
from trendify.formats.format2d import Format2D
|
|
30
|
+
from trendify.styling.grid import Grid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["PlotlyFigure", "SingleAxisFigure"]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# This pipeline only ever renders headlessly (batch CLI/worker processes, no interactive
|
|
38
|
+
# window), so pin the backend explicitly before the first figure is ever created. Left
|
|
39
|
+
# unset, pyplot's lazy backend auto-selection probes for a live GUI display on the very
|
|
40
|
+
# first `plt.figure()` call (`matplotlib._c_internal_utils.display_is_valid`), which is a
|
|
41
|
+
# multi-second stall on some systems for a check whose answer we don't care about anyway.
|
|
42
|
+
matplotlib.use("Agg")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SingleAxisFigure:
|
|
47
|
+
"""
|
|
48
|
+
Data class storing a matlab figure and axis. The stored tag data in this class is so-far unused.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
52
|
+
|
|
53
|
+
tag: Tag
|
|
54
|
+
"""Figure tag. Not yet used."""
|
|
55
|
+
|
|
56
|
+
fig: Figure
|
|
57
|
+
"""Matplotlib figure."""
|
|
58
|
+
|
|
59
|
+
ax: Axes
|
|
60
|
+
"""Matplotlib axis to which data will be plotted."""
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def new(cls, tag: Tag):
|
|
64
|
+
"""
|
|
65
|
+
Creates new figure and axis. Returns new instance of this class.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
tag (Tag): tag (not yet used)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
(Type[Self]): New single axis figure
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
fig = plt.figure()
|
|
75
|
+
ax = fig.add_subplot(1, 1, 1)
|
|
76
|
+
return cls(
|
|
77
|
+
tag=tag,
|
|
78
|
+
fig=fig,
|
|
79
|
+
ax=ax,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def apply_format(self, format2d: Format2D) -> Self:
|
|
83
|
+
"""
|
|
84
|
+
Applies format to figure and axes labels and limits
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
format2d (Format2D): format information to apply to the single axis figure
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
if format2d.title_ax is not None:
|
|
91
|
+
self.ax.set_title(format2d.title_ax)
|
|
92
|
+
if format2d.title_fig is not None:
|
|
93
|
+
self.fig.suptitle(format2d.title_fig)
|
|
94
|
+
|
|
95
|
+
leg = None
|
|
96
|
+
if format2d.legend is not None:
|
|
97
|
+
with warnings.catch_warnings(action="ignore", category=UserWarning):
|
|
98
|
+
handles, labels = self.ax.get_legend_handles_labels()
|
|
99
|
+
by_label = dict(zip(labels, handles))
|
|
100
|
+
if by_label:
|
|
101
|
+
sorted_items = sorted(by_label.items(), key=lambda item: item[0])
|
|
102
|
+
labels_sorted, handles_sorted = zip(*sorted_items)
|
|
103
|
+
|
|
104
|
+
kwargs = format2d.legend.to_kwargs()
|
|
105
|
+
|
|
106
|
+
leg = self.ax.legend(
|
|
107
|
+
handles=handles_sorted,
|
|
108
|
+
labels=labels_sorted,
|
|
109
|
+
bbox_to_anchor=format2d.legend.bbox_to_anchor,
|
|
110
|
+
**kwargs,
|
|
111
|
+
)
|
|
112
|
+
leg.set_zorder(level=format2d.legend.zorder)
|
|
113
|
+
|
|
114
|
+
if leg is not None and format2d.legend.edgecolor:
|
|
115
|
+
leg.get_frame().set_edgecolor(format2d.legend.edgecolor)
|
|
116
|
+
|
|
117
|
+
if format2d.label_x is not None:
|
|
118
|
+
self.ax.set_xlabel(xlabel=format2d.label_x)
|
|
119
|
+
if format2d.label_y is not None:
|
|
120
|
+
self.ax.set_ylabel(ylabel=format2d.label_y)
|
|
121
|
+
|
|
122
|
+
self.ax.set_xlim(left=format2d.lim_x[0], right=format2d.lim_x[1])
|
|
123
|
+
self.ax.set_ylim(bottom=format2d.lim_y[0], top=format2d.lim_y[1])
|
|
124
|
+
|
|
125
|
+
self.ax.set_xscale(format2d.scale_x.value)
|
|
126
|
+
self.ax.set_yscale(format2d.scale_y.value)
|
|
127
|
+
|
|
128
|
+
if format2d.grid is not None:
|
|
129
|
+
self.apply_grid(format2d.grid)
|
|
130
|
+
|
|
131
|
+
self.fig.set_size_inches(format2d.figure_width, format2d.figure_height)
|
|
132
|
+
self.fig.tight_layout(rect=(0, 0.03, 1, 0.95))
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def apply_grid(self, grid: Grid) -> None:
|
|
136
|
+
self.ax.set_axisbelow(True)
|
|
137
|
+
|
|
138
|
+
# Major grid
|
|
139
|
+
if grid.major.show:
|
|
140
|
+
self.ax.grid(
|
|
141
|
+
visible=True,
|
|
142
|
+
which="major",
|
|
143
|
+
color=grid.major.pen.color,
|
|
144
|
+
linestyle=grid.major.pen.linestyle,
|
|
145
|
+
linewidth=grid.major.pen.size,
|
|
146
|
+
alpha=grid.major.pen.alpha,
|
|
147
|
+
zorder=grid.zorder,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
self.ax.grid(visible=False, which="major")
|
|
151
|
+
|
|
152
|
+
# Minor ticks and grid
|
|
153
|
+
if grid.enable_minor_ticks:
|
|
154
|
+
self.ax.minorticks_on()
|
|
155
|
+
else:
|
|
156
|
+
self.ax.minorticks_off()
|
|
157
|
+
|
|
158
|
+
if grid.minor.show:
|
|
159
|
+
self.ax.grid(
|
|
160
|
+
visible=True,
|
|
161
|
+
which="minor",
|
|
162
|
+
color=grid.minor.pen.color,
|
|
163
|
+
linestyle=grid.minor.pen.linestyle,
|
|
164
|
+
linewidth=grid.minor.pen.size,
|
|
165
|
+
alpha=grid.minor.pen.alpha,
|
|
166
|
+
zorder=grid.zorder,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
self.ax.grid(visible=False, which="minor")
|
|
170
|
+
|
|
171
|
+
def savefig(self, path: Path, dpi: int | None = None) -> Self:
|
|
172
|
+
"""
|
|
173
|
+
Wrapper on matplotlib savefig method. Saves figure to given path, at the given `dpi`
|
|
174
|
+
for raster output. `dpi` is meaningless for vector output (e.g. `.svg`), so `None`
|
|
175
|
+
leaves it unset and matplotlib infers the format from `path`'s suffix either way.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
(Self): Returns self
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
logger.debug(f"Saving matplotlib figure for {self.tag = } to {path} ({dpi = })")
|
|
182
|
+
if dpi is not None:
|
|
183
|
+
self.fig.savefig(path, dpi=dpi)
|
|
184
|
+
else:
|
|
185
|
+
self.fig.savefig(path)
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def __del__(self):
|
|
189
|
+
"""
|
|
190
|
+
Closes stored matplotlib figure before deleting reference to object.
|
|
191
|
+
"""
|
|
192
|
+
plt.close(self.fig)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class PlotlyFigure:
|
|
197
|
+
"""
|
|
198
|
+
Data class storing a Plotly figure alongside the legend groups added to it so far.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
202
|
+
|
|
203
|
+
tag: Tag
|
|
204
|
+
"""Figure tag. Not yet used."""
|
|
205
|
+
|
|
206
|
+
fig: go.Figure
|
|
207
|
+
"""Plotly figure."""
|
|
208
|
+
|
|
209
|
+
legend_groups: set[str] = field(default_factory=set)
|
|
210
|
+
"""Legend group keys already added to `fig`, used to avoid duplicate legend entries when
|
|
211
|
+
multiple records share a style."""
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def new(cls, tag: Tag):
|
|
215
|
+
"""
|
|
216
|
+
Creates new figure and axis. Returns new instance of this class.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
tag (Tag): tag (not yet used)
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
(Type[Self]): New single axis figure
|
|
223
|
+
|
|
224
|
+
"""
|
|
225
|
+
fig = go.Figure()
|
|
226
|
+
return cls(tag=tag, fig=fig)
|
|
227
|
+
|
|
228
|
+
def apply_format(self, format2d: Format2D) -> Self:
|
|
229
|
+
"""
|
|
230
|
+
Applies format to Plotly figure layout including axes labels and limits
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
format2d (Format2D): format information to apply to the figure
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
layout_updates: dict[str, Any] = {}
|
|
237
|
+
|
|
238
|
+
# `title_fig` maps to Plotly's main `title.text` (matches matplotlib's
|
|
239
|
+
# `fig.suptitle`); `title_ax` maps to Plotly's `title.subtitle.text` (matches
|
|
240
|
+
# matplotlib's `ax.set_title` sitting just below it), rather than concatenating
|
|
241
|
+
# both into one string.
|
|
242
|
+
title: dict[str, Any] = {}
|
|
243
|
+
if format2d.title_fig is not None:
|
|
244
|
+
title["text"] = format2d.title_fig
|
|
245
|
+
if format2d.title_ax is not None:
|
|
246
|
+
title["subtitle"] = {"text": format2d.title_ax}
|
|
247
|
+
if title:
|
|
248
|
+
layout_updates["title"] = title
|
|
249
|
+
|
|
250
|
+
# Set axis labels
|
|
251
|
+
if format2d.label_x is not None:
|
|
252
|
+
layout_updates["xaxis"] = cast(dict[str, Any], {"title": format2d.label_x})
|
|
253
|
+
if format2d.label_y is not None:
|
|
254
|
+
layout_updates["yaxis"] = cast(dict[str, Any], {"title": format2d.label_y})
|
|
255
|
+
|
|
256
|
+
# Set axis ranges
|
|
257
|
+
def _log10(v: float | None) -> float | None:
|
|
258
|
+
return None if v is None else np.log10(v)
|
|
259
|
+
|
|
260
|
+
if format2d.lim_x[0] is not None or format2d.lim_x[1] is not None:
|
|
261
|
+
if "xaxis" not in layout_updates:
|
|
262
|
+
layout_updates["xaxis"] = cast(dict[str, Any], {})
|
|
263
|
+
|
|
264
|
+
if format2d.scale_x == AxisScale.LOG:
|
|
265
|
+
layout_updates["xaxis"]["range"] = [
|
|
266
|
+
_log10(format2d.lim_x[0]),
|
|
267
|
+
_log10(format2d.lim_x[1]),
|
|
268
|
+
]
|
|
269
|
+
elif format2d.scale_x == AxisScale.LINEAR:
|
|
270
|
+
# Deliberately checks LINEAR explicitly rather than falling through an
|
|
271
|
+
# unconditional else, so a third AxisScale value can't silently reuse the
|
|
272
|
+
# log-scaled range below.
|
|
273
|
+
layout_updates["xaxis"]["range"] = [
|
|
274
|
+
format2d.lim_x[0],
|
|
275
|
+
format2d.lim_x[1],
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
if format2d.lim_y[0] is not None or format2d.lim_y[1] is not None:
|
|
279
|
+
if "yaxis" not in layout_updates:
|
|
280
|
+
layout_updates["yaxis"] = cast(dict[str, Any], {})
|
|
281
|
+
if format2d.scale_y == AxisScale.LOG:
|
|
282
|
+
layout_updates["yaxis"]["range"] = [
|
|
283
|
+
_log10(format2d.lim_y[0]),
|
|
284
|
+
_log10(format2d.lim_y[1]),
|
|
285
|
+
]
|
|
286
|
+
elif format2d.scale_y == AxisScale.LINEAR:
|
|
287
|
+
# Same reasoning as the x-axis branch above.
|
|
288
|
+
layout_updates["yaxis"]["range"] = [
|
|
289
|
+
format2d.lim_y[0],
|
|
290
|
+
format2d.lim_y[1],
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
# Set axis scales
|
|
294
|
+
if format2d.scale_x is not None:
|
|
295
|
+
if "xaxis" not in layout_updates:
|
|
296
|
+
layout_updates["xaxis"] = cast(dict[str, Any], {})
|
|
297
|
+
layout_updates["xaxis"]["type"] = format2d.scale_x.value
|
|
298
|
+
|
|
299
|
+
if format2d.scale_y is not None:
|
|
300
|
+
if "yaxis" not in layout_updates:
|
|
301
|
+
layout_updates["yaxis"] = cast(dict[str, Any], {})
|
|
302
|
+
layout_updates["yaxis"]["type"] = format2d.scale_y.value
|
|
303
|
+
|
|
304
|
+
# Set legend
|
|
305
|
+
if format2d.legend is not None:
|
|
306
|
+
layout_updates["showlegend"] = format2d.legend.visible
|
|
307
|
+
layout_updates["legend"] = dict(
|
|
308
|
+
title=format2d.legend.title,
|
|
309
|
+
bordercolor=format2d.legend.edgecolor,
|
|
310
|
+
**format2d.legend.plotly_location,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Apply grid if specified
|
|
314
|
+
if format2d.grid is not None:
|
|
315
|
+
self.apply_grid(format2d.grid)
|
|
316
|
+
|
|
317
|
+
# Update layout
|
|
318
|
+
self.fig.update_layout(**layout_updates)
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
def apply_grid(self, grid: Grid) -> None:
|
|
322
|
+
"""
|
|
323
|
+
Applies grid settings to the Plotly figure
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
grid (Grid): Grid configuration to apply
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
# Major grid
|
|
330
|
+
|
|
331
|
+
xaxis_updates = {
|
|
332
|
+
"showgrid": grid.major.show,
|
|
333
|
+
"gridcolor": grid.major.pen.rgba if grid.major.show else None,
|
|
334
|
+
"gridwidth": grid.major.pen.size if grid.major.show else None,
|
|
335
|
+
"griddash": "solid" if grid.major.pen.linestyle == "-" else "dash",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
yaxis_updates = {
|
|
339
|
+
"showgrid": grid.major.show,
|
|
340
|
+
"gridcolor": grid.major.pen.rgba if grid.major.show else None,
|
|
341
|
+
"gridwidth": grid.major.pen.size if grid.major.show else None,
|
|
342
|
+
"griddash": "solid" if grid.major.pen.linestyle == "-" else "dash",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Minor ticks and grid
|
|
346
|
+
if grid.enable_minor_ticks:
|
|
347
|
+
xaxis_updates["minor"] = {
|
|
348
|
+
"showgrid": grid.minor.show,
|
|
349
|
+
"gridcolor": grid.minor.pen.rgba if grid.minor.show else None,
|
|
350
|
+
"gridwidth": grid.minor.pen.size if grid.minor.show else None,
|
|
351
|
+
"griddash": "solid" if grid.minor.pen.linestyle == "-" else "dash",
|
|
352
|
+
}
|
|
353
|
+
yaxis_updates["minor"] = {
|
|
354
|
+
"showgrid": grid.minor.show,
|
|
355
|
+
"gridcolor": grid.minor.pen.rgba if grid.minor.show else None,
|
|
356
|
+
"gridwidth": grid.minor.pen.size if grid.minor.show else None,
|
|
357
|
+
"griddash": "solid" if grid.minor.pen.linestyle == "-" else "dash",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
self.fig.update_xaxes(**xaxis_updates)
|
|
361
|
+
self.fig.update_yaxes(**yaxis_updates)
|
|
362
|
+
|
|
363
|
+
def add_record(self, record: PlottableData2D) -> PlotlyFigure:
|
|
364
|
+
"""
|
|
365
|
+
Add a record to the figure. Every trace `record.add_to_plotly` adds (there's always at
|
|
366
|
+
most one -- `AxLine` adds none, since it renders as a layout shape instead) gets
|
|
367
|
+
`record.metadata` attached as its Plotly `meta` attribute: not rendered by Plotly
|
|
368
|
+
itself, but present verbatim in `to_plotly_json()`'s output, which is how the viewer's
|
|
369
|
+
`/api/plot` response lets the dashboard filter traces by metadata client-side without
|
|
370
|
+
a separate endpoint or a schema change (see `viewer/templates/.../metadata-filter.ts`).
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
record (PlottableData2D): Record to add to figure
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Self: Returns self for method chaining
|
|
377
|
+
|
|
378
|
+
"""
|
|
379
|
+
# `fig.data`'s dynamically-generated attributes defeat pyright's static analysis here
|
|
380
|
+
# the same way `fig.layout`'s do in test_figure.py's `_layout` helper -- `Any` is a
|
|
381
|
+
# deliberate, narrow escape hatch at this one boundary, not a blanket opt-out. Read
|
|
382
|
+
# fresh (not cached across `add_to_plotly`): `fig.data` is an immutable tuple, so
|
|
383
|
+
# `add_trace` rebinds it to a new, longer one rather than mutating in place.
|
|
384
|
+
n_before = len(cast(Any, self.fig.data))
|
|
385
|
+
record.add_to_plotly(self)
|
|
386
|
+
if record.metadata:
|
|
387
|
+
for trace in cast(Any, self.fig.data)[n_before:]:
|
|
388
|
+
trace.meta = record.metadata
|
|
389
|
+
return self
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`HistogramEntry` (a single value to be binned into a matplotlib/Plotly histogram) and its
|
|
3
|
+
`HistogramStyle` (bar color, opacity, bin count, and the derived RGBA/contrast-color helpers
|
|
4
|
+
used by both renderers).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import plotly.graph_objects as go
|
|
14
|
+
from matplotlib.colors import to_rgba
|
|
15
|
+
from pydantic import ConfigDict, Field, field_validator, model_validator
|
|
16
|
+
|
|
17
|
+
from trendify.base.helpers import HashableBase, Tags
|
|
18
|
+
from trendify.formats.format2d import PlottableData2D
|
|
19
|
+
from trendify.plotting.figure import PlotlyFigure
|
|
20
|
+
from trendify.typing import VecN
|
|
21
|
+
|
|
22
|
+
__all__ = ["HistogramEntry", "HistogramStyle"]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HistogramStyle(HashableBase):
|
|
28
|
+
"""
|
|
29
|
+
Label and style data for generating histogram bars
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
color: str = "k"
|
|
33
|
+
"""Color of bars"""
|
|
34
|
+
|
|
35
|
+
label: str | None = None
|
|
36
|
+
"""Legend entry"""
|
|
37
|
+
|
|
38
|
+
histtype: Literal["bar", "step", "stepfilled"] = "stepfilled"
|
|
39
|
+
"""Histogram type corresponding to matplotlib argument of same name"""
|
|
40
|
+
|
|
41
|
+
alpha_edge: float = 1.0
|
|
42
|
+
"""Opacity of bar edge"""
|
|
43
|
+
|
|
44
|
+
alpha_face: float = 0.3
|
|
45
|
+
"""Opacity of bar face"""
|
|
46
|
+
|
|
47
|
+
linewidth: float = 2.0
|
|
48
|
+
"""Line width of bar outline"""
|
|
49
|
+
|
|
50
|
+
zorder: int = 1
|
|
51
|
+
"""Prioritization"""
|
|
52
|
+
|
|
53
|
+
bins: int | tuple[int, ...] | None = None
|
|
54
|
+
"""Number of bins (see [matplotlib docs][matplotlib.pyplot.hist])"""
|
|
55
|
+
|
|
56
|
+
@field_validator("bins", mode="before")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _coerce_bins_to_hashable(
|
|
59
|
+
cls, value: int | list[int] | tuple[int] | VecN | None
|
|
60
|
+
):
|
|
61
|
+
# `HistogramStyle` is a `HashableBase` (its instances are deduplicated via `set()` in
|
|
62
|
+
# `Histogrammer.handle_histogram_entries`), so `bins` can't be stored as a `list` or
|
|
63
|
+
# `np.ndarray`: both are unhashable and would break `HashableBase.__hash__`.
|
|
64
|
+
if isinstance(value, (list, np.ndarray)):
|
|
65
|
+
return tuple(int(v) for v in value)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _visible_edge_for_step_histtype(self) -> HistogramStyle:
|
|
70
|
+
# `histtype="step"` draws an edge-only patch (`fill=False`), so `alpha_edge` is the
|
|
71
|
+
# only thing that can make it visible at all, unlike "bar"/"stepfilled" where the
|
|
72
|
+
# face fill carries the color and a zero-alpha edge is just borderless.
|
|
73
|
+
# `alpha_edge`'s class default of 0 is right for those other histtypes but leaves a
|
|
74
|
+
# "step" histogram completely invisible, so bump it to opaque if left at zero.
|
|
75
|
+
if self.histtype == "step" and self.alpha_edge == 0:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Histogram type was set to 'step' but alpha_edge was 0, coercing edge transparency to 1"
|
|
78
|
+
)
|
|
79
|
+
self.alpha_edge = 1
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def as_plot_kwargs(self) -> dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Returns:
|
|
85
|
+
(dict): kwargs for matplotlib `hist` method
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
return {
|
|
89
|
+
"facecolor": (self.color, self.alpha_face),
|
|
90
|
+
"edgecolor": (self.color, self.alpha_edge),
|
|
91
|
+
"linewidth": self.linewidth,
|
|
92
|
+
"label": self.label,
|
|
93
|
+
"histtype": self.histtype,
|
|
94
|
+
"bins": self.bins,
|
|
95
|
+
"zorder": self.zorder,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def rgba_face(self) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Convert the pen's color to rgba string format.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
str: Color in 'rgba(r,g,b,a)' format where r,g,b are 0-255 and a is 0-1
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
# Handle different color input formats
|
|
108
|
+
if isinstance(self.color, tuple):
|
|
109
|
+
if len(self.color) == 3: # RGB tuple
|
|
110
|
+
r, g, b = self.color
|
|
111
|
+
a = self.alpha_face
|
|
112
|
+
else: # RGBA tuple
|
|
113
|
+
r, g, b, a = self.color
|
|
114
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
115
|
+
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
|
116
|
+
else: # String color (name or hex)
|
|
117
|
+
# Use matplotlib's color converter
|
|
118
|
+
rgba_vals = to_rgba(self.color, self.alpha_face)
|
|
119
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
120
|
+
r, g, b = [int(x * 255) for x in rgba_vals[:3]]
|
|
121
|
+
a = rgba_vals[3]
|
|
122
|
+
|
|
123
|
+
return f"rgba({r}, {g}, {b}, {a})"
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def rgba_edge(self) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Convert the pen's color to rgba string format.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
str: Color in 'rgba(r,g,b,a)' format where r,g,b are 0-255 and a is 0-1
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
# Handle different color input formats
|
|
135
|
+
if isinstance(self.color, tuple):
|
|
136
|
+
if len(self.color) == 3: # RGB tuple
|
|
137
|
+
r, g, b = self.color
|
|
138
|
+
a = self.alpha_edge
|
|
139
|
+
else: # RGBA tuple
|
|
140
|
+
r, g, b, a = self.color
|
|
141
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
142
|
+
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
|
143
|
+
else: # String color (name or hex)
|
|
144
|
+
# Use matplotlib's color converter
|
|
145
|
+
rgba_vals = to_rgba(self.color, self.alpha_edge)
|
|
146
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
147
|
+
r, g, b = [int(x * 255) for x in rgba_vals[:3]]
|
|
148
|
+
a = rgba_vals[3]
|
|
149
|
+
|
|
150
|
+
return f"rgba({r}, {g}, {b}, {a})"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def rgb_face(self) -> str:
|
|
154
|
+
return ", ".join(self.rgba_face.split(",")[0:-1]) + ")"
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def rgb_edge(self) -> str:
|
|
158
|
+
return ", ".join(self.rgba_edge.split(",")[0:-1]) + ")"
|
|
159
|
+
|
|
160
|
+
def get_face_contrast_color(self, background_luminance: float = 1.0) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Returns 'white' or 'black' to provide the best contrast against the pen's color,
|
|
163
|
+
taking into account the alpha (transparency) value of the line.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
background_luminance (float): The luminance of the background (default is 1.0 for white).
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
str: 'white' or 'black'
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
# Convert the pen's color to RGB (0-255 range) and get alpha
|
|
173
|
+
if isinstance(self.color, tuple):
|
|
174
|
+
if len(self.color) == 3: # RGB tuple
|
|
175
|
+
r, g, b = self.color
|
|
176
|
+
a = self.alpha_face
|
|
177
|
+
else: # RGBA tuple
|
|
178
|
+
r, g, b, a = self.color
|
|
179
|
+
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
|
180
|
+
else: # String color (name or hex)
|
|
181
|
+
rgba_vals = to_rgba(self.color, self.alpha_face)
|
|
182
|
+
r, g, b = [int(x * 255) for x in rgba_vals[:3]]
|
|
183
|
+
a = rgba_vals[3]
|
|
184
|
+
|
|
185
|
+
# Calculate relative luminance of the pen's color
|
|
186
|
+
def luminance(channel: float) -> float:
|
|
187
|
+
channel /= 255.0
|
|
188
|
+
return (
|
|
189
|
+
channel / 12.92
|
|
190
|
+
if channel <= 0.03928
|
|
191
|
+
else ((channel + 0.055) / 1.055) ** 2.4
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
color_luminance = (
|
|
195
|
+
0.2126 * luminance(r) + 0.7152 * luminance(g) + 0.0722 * luminance(b)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Blend the color luminance with the background luminance based on alpha
|
|
199
|
+
blended_luminance = (1 - a) * background_luminance + a * color_luminance
|
|
200
|
+
|
|
201
|
+
# Return white for dark blended colors, black for light blended colors
|
|
202
|
+
return "white" if blended_luminance < 0.5 else "black"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class HistogramEntry(PlottableData2D):
|
|
206
|
+
"""
|
|
207
|
+
Use this class to specify a value to be collected into a matplotlib histogram.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
value: float | str
|
|
211
|
+
"""Value to be binned"""
|
|
212
|
+
|
|
213
|
+
tags: Tags
|
|
214
|
+
"""Tags used to sort records"""
|
|
215
|
+
|
|
216
|
+
style: HistogramStyle = Field(default_factory=HistogramStyle)
|
|
217
|
+
"""Style of histogram display"""
|
|
218
|
+
|
|
219
|
+
model_config = ConfigDict(extra="forbid")
|
|
220
|
+
|
|
221
|
+
def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
|
|
222
|
+
"""Add histogram entry to plotly figure, merging with existing traces if possible"""
|
|
223
|
+
# Create legend group key based on the label and color
|
|
224
|
+
legend_key = (
|
|
225
|
+
f"{self.style.label}_{self.style.rgba_face}" if self.style.label else None
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Check if a trace with the same legend group already exists
|
|
229
|
+
traces = cast("tuple[go.Histogram, ...]", plotly_figure.fig.data)
|
|
230
|
+
for trace in traces:
|
|
231
|
+
if trace.legendgroup == legend_key:
|
|
232
|
+
# Append the value to the existing trace's x data
|
|
233
|
+
trace.x = [*(trace.x or ()), self.value]
|
|
234
|
+
return plotly_figure
|
|
235
|
+
|
|
236
|
+
metadata_html = (
|
|
237
|
+
"<br>".join([f"{key}: {value}" for key, value in self.metadata.items()])
|
|
238
|
+
if self.metadata
|
|
239
|
+
else ""
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
hovertemplate = (
|
|
243
|
+
f"<b>{self.style.label if self.style.label else ''}</b><br>"
|
|
244
|
+
"x: %{x}<br>"
|
|
245
|
+
"y: %{y}<br>"
|
|
246
|
+
f"{metadata_html}<extra></extra>"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# If no existing trace, add a new one
|
|
250
|
+
plotly_figure.fig.add_trace(
|
|
251
|
+
go.Histogram(
|
|
252
|
+
x=[self.value],
|
|
253
|
+
name=self.style.label, # Legend label
|
|
254
|
+
marker=dict(
|
|
255
|
+
color=self.style.rgba_face,
|
|
256
|
+
line=dict(
|
|
257
|
+
color=self.style.rgba_edge,
|
|
258
|
+
width=self.style.linewidth,
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
zorder=self.style.zorder,
|
|
262
|
+
nbinsx=self.style.bins if isinstance(self.style.bins, int) else None,
|
|
263
|
+
legendgroup=legend_key, # Group histograms with the same label and color
|
|
264
|
+
hovertemplate=hovertemplate,
|
|
265
|
+
hoverlabel=dict(
|
|
266
|
+
bgcolor=self.style.rgba_face,
|
|
267
|
+
bordercolor=self.style.rgba_edge,
|
|
268
|
+
font=dict(
|
|
269
|
+
color=self.style.get_face_contrast_color() # Optionally, set the font color as well
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
showlegend=(
|
|
273
|
+
True if self.style.label is not None else False
|
|
274
|
+
), # Always show legend for the first trace
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Track the legend group to avoid duplicate legend entries
|
|
279
|
+
if legend_key and legend_key not in plotly_figure.legend_groups:
|
|
280
|
+
plotly_figure.legend_groups.add(legend_key)
|
|
281
|
+
|
|
282
|
+
return plotly_figure
|