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.
Files changed (76) hide show
  1. trendify/__about__.py +14 -0
  2. trendify/__init__.py +250 -0
  3. trendify/base/__init__.py +36 -0
  4. trendify/base/helpers.py +76 -0
  5. trendify/base/pen.py +161 -0
  6. trendify/base/record.py +148 -0
  7. trendify/cli.py +403 -0
  8. trendify/color.py +260 -0
  9. trendify/examples.py +474 -0
  10. trendify/formats/__init__.py +28 -0
  11. trendify/formats/format2d.py +128 -0
  12. trendify/formats/table.py +55 -0
  13. trendify/generator/__init__.py +38 -0
  14. trendify/generator/generate.py +167 -0
  15. trendify/generator/histogrammer.py +62 -0
  16. trendify/generator/render.py +195 -0
  17. trendify/generator/table_builder.py +194 -0
  18. trendify/generator/xy_data_plotter.py +195 -0
  19. trendify/log.py +195 -0
  20. trendify/pipeline.py +136 -0
  21. trendify/plotting/__init__.py +48 -0
  22. trendify/plotting/axline.py +122 -0
  23. trendify/plotting/figure.py +389 -0
  24. trendify/plotting/histogram.py +282 -0
  25. trendify/plotting/point.py +89 -0
  26. trendify/plotting/scatter.py +100 -0
  27. trendify/plotting/trace.py +129 -0
  28. trendify/progress.py +43 -0
  29. trendify/store/__init__.py +30 -0
  30. trendify/store/db.py +119 -0
  31. trendify/store/record_store.py +448 -0
  32. trendify/store/tags.py +65 -0
  33. trendify/styling/__init__.py +30 -0
  34. trendify/styling/grid.py +125 -0
  35. trendify/styling/legend.py +217 -0
  36. trendify/styling/marker.py +168 -0
  37. trendify/typing.py +26 -0
  38. trendify/viewer/__init__.py +41 -0
  39. trendify/viewer/app.py +208 -0
  40. trendify/viewer/hydration.py +181 -0
  41. trendify/viewer/plot_config.py +102 -0
  42. trendify/viewer/routes/__init__.py +6 -0
  43. trendify/viewer/routes/api.py +360 -0
  44. trendify/viewer/routes/pages.py +39 -0
  45. trendify/viewer/tag_tree.py +148 -0
  46. trendify/viewer/templates/base.html +63 -0
  47. trendify/viewer/templates/index.html +131 -0
  48. trendify/viewer/templates/partials/__init__.py +1 -0
  49. trendify/viewer/templates/partials/footer.html +15 -0
  50. trendify/viewer/templates/partials/nav.html +181 -0
  51. trendify/viewer/templates/partials/plot_view.html +221 -0
  52. trendify/viewer/templates/partials/sidebar.html +163 -0
  53. trendify/viewer/templates/partials/table_view.html +43 -0
  54. trendify/viewer/templates/static/__init__.py +1 -0
  55. trendify/viewer/templates/static/assets/__init__.py +1 -0
  56. trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
  57. trendify/viewer/templates/static/assets/main-logo.svg +66 -0
  58. trendify/viewer/templates/static/css/app.css +75 -0
  59. trendify/viewer/templates/static/js/main.js +2 -0
  60. trendify/viewer/templates/static/vendored/__init__.py +1 -0
  61. trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
  62. trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
  63. trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
  64. trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
  65. trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
  66. trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
  67. trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
  68. trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
  69. trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
  70. trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
  71. trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
  72. trendify2-2.0.4.dist-info/METADATA +107 -0
  73. trendify2-2.0.4.dist-info/RECORD +76 -0
  74. trendify2-2.0.4.dist-info/WHEEL +4 -0
  75. trendify2-2.0.4.dist-info/entry_points.txt +2 -0
  76. 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