youplot 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- youplot/__init__.py +37 -0
- youplot/colors/__init__.py +0 -0
- youplot/colors/palette.py +106 -0
- youplot/examples/basic_line.py +123 -0
- youplot/figure.py +518 -0
- youplot/options/__init__.py +0 -0
- youplot/options/annotations.py +60 -0
- youplot/options/axes.py +22 -0
- youplot/render/css.py +578 -0
- youplot/render/html.py +320 -0
- youplot/render/js.py +1080 -0
- youplot/render/scatter_js.py +195 -0
- youplot/render/serializer.py +242 -0
- youplot/series/__init__.py +2 -0
- youplot/series/line.py +70 -0
- youplot/series/scatter.py +76 -0
- youplot/themes/__init__.py +0 -0
- youplot/themes/base.py +105 -0
- youplot/utils/__init__.py +0 -0
- youplot/utils/browser.py +22 -0
- youplot/utils/data.py +150 -0
- youplot/vendor/__init__.py +12 -0
- youplot/vendor/__pycache__/__init__.cpython-311.pyc +0 -0
- youplot/vendor/uplot.iife.min.js +2 -0
- youplot/vendor/uplot.min.css +1 -0
- youplot-1.0.0.dist-info/METADATA +224 -0
- youplot-1.0.0.dist-info/RECORD +30 -0
- youplot-1.0.0.dist-info/WHEEL +5 -0
- youplot-1.0.0.dist-info/licenses/LICENSE +21 -0
- youplot-1.0.0.dist-info/top_level.txt +1 -0
youplot/figure.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Figure — top-level user API for youplot.
|
|
3
|
+
Supports line and scatter series, mixed on the same chart.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from typing import Any
|
|
8
|
+
from datetime import datetime, date
|
|
9
|
+
|
|
10
|
+
from youplot.series.line import LineSeries
|
|
11
|
+
from youplot.series.scatter import ScatterSeries
|
|
12
|
+
from youplot.options.annotations import VLine, HLine, Region, Band, Pin
|
|
13
|
+
from youplot.colors.palette import ColorCycle, resolve
|
|
14
|
+
from youplot.themes.base import get as get_theme
|
|
15
|
+
from youplot.utils.data import (
|
|
16
|
+
to_float_list, to_timestamp_ms_list, validate_xy,
|
|
17
|
+
inject_gaps, apply_null_handling,
|
|
18
|
+
)
|
|
19
|
+
from youplot.render.html import build_html
|
|
20
|
+
from youplot.utils.browser import show as _show, save as _save
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Figure:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
title: str = "",
|
|
27
|
+
subtitle: str = "",
|
|
28
|
+
theme: str = "light",
|
|
29
|
+
width: int | str = "100%",
|
|
30
|
+
height: int = 380,
|
|
31
|
+
x_label: str = "",
|
|
32
|
+
y_label: str = "",
|
|
33
|
+
y_right_label: str = "",
|
|
34
|
+
x_format: str = "",
|
|
35
|
+
y_format: str = "",
|
|
36
|
+
x_range: list | None = None,
|
|
37
|
+
y_range: list | None = None,
|
|
38
|
+
y_right_range: list | None = None,
|
|
39
|
+
grid: bool = True,
|
|
40
|
+
zoom: bool = True,
|
|
41
|
+
legend: bool = True,
|
|
42
|
+
hover: bool = True,
|
|
43
|
+
):
|
|
44
|
+
self.title = title
|
|
45
|
+
self.subtitle = subtitle
|
|
46
|
+
self.theme = get_theme(theme)
|
|
47
|
+
self.width = width
|
|
48
|
+
self.height = height
|
|
49
|
+
self.x_label = x_label
|
|
50
|
+
self.y_label = y_label
|
|
51
|
+
self.y_right_label = y_right_label
|
|
52
|
+
self.x_format = x_format
|
|
53
|
+
self.y_format = y_format
|
|
54
|
+
self.x_range = x_range
|
|
55
|
+
self.y_range = y_range
|
|
56
|
+
self.y_right_range = y_right_range
|
|
57
|
+
self.grid = grid
|
|
58
|
+
self.zoom = zoom
|
|
59
|
+
self.legend = legend
|
|
60
|
+
self.hover = hover
|
|
61
|
+
|
|
62
|
+
self._line_series: list[LineSeries] = []
|
|
63
|
+
self._scatter_series: list[ScatterSeries] = []
|
|
64
|
+
self._vlines: list[VLine] = []
|
|
65
|
+
self._hlines: list[HLine] = []
|
|
66
|
+
self._regions: list[Region] = []
|
|
67
|
+
self._bands: list[Band] = []
|
|
68
|
+
self._pins: list[Pin] = []
|
|
69
|
+
self._color_cycle = ColorCycle(dark=(theme == "dark"))
|
|
70
|
+
|
|
71
|
+
# insertion order for legend
|
|
72
|
+
self._series_order: list[tuple[str, int]] = [] # ("line"|"scatter", idx)
|
|
73
|
+
|
|
74
|
+
# ── Line ──────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def line(
|
|
77
|
+
self,
|
|
78
|
+
x: Any,
|
|
79
|
+
y: Any,
|
|
80
|
+
label: str = "",
|
|
81
|
+
color: str | None = None,
|
|
82
|
+
width: float = 2.0,
|
|
83
|
+
opacity: float = 1.0,
|
|
84
|
+
dash: bool | list[int] = False,
|
|
85
|
+
fill: bool = False,
|
|
86
|
+
fill_opacity: float = 0.15,
|
|
87
|
+
fill_color: str = "",
|
|
88
|
+
axis: str = "left",
|
|
89
|
+
points: bool = False,
|
|
90
|
+
points_size: float = 4.0,
|
|
91
|
+
points_color: str = "",
|
|
92
|
+
points_filled: bool = True,
|
|
93
|
+
smooth: bool = False,
|
|
94
|
+
step: bool = False,
|
|
95
|
+
gap_threshold: float | None = None,
|
|
96
|
+
null_handling: str = "gap",
|
|
97
|
+
hover_format: str = "",
|
|
98
|
+
hover_unit: str = "",
|
|
99
|
+
) -> "Figure":
|
|
100
|
+
validate_xy(x, y, label)
|
|
101
|
+
resolved_color = resolve(color) if color else self._color_cycle.next()
|
|
102
|
+
s = LineSeries(
|
|
103
|
+
x=x, y=y, label=label,
|
|
104
|
+
color=resolved_color, width=width, opacity=opacity, dash=dash,
|
|
105
|
+
fill=fill, fill_opacity=fill_opacity, fill_color=fill_color,
|
|
106
|
+
axis=axis,
|
|
107
|
+
points=points, points_size=points_size,
|
|
108
|
+
points_color=points_color, points_filled=points_filled,
|
|
109
|
+
smooth=smooth, step=step,
|
|
110
|
+
gap_threshold=gap_threshold, null_handling=null_handling,
|
|
111
|
+
hover_format=hover_format, hover_unit=hover_unit,
|
|
112
|
+
)
|
|
113
|
+
idx = len(self._line_series)
|
|
114
|
+
self._line_series.append(s)
|
|
115
|
+
self._series_order.append(("line", idx))
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
# ── Scatter ───────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def scatter(
|
|
121
|
+
self,
|
|
122
|
+
x: Any,
|
|
123
|
+
y: Any,
|
|
124
|
+
label: str = "",
|
|
125
|
+
color: str | None = None,
|
|
126
|
+
size: float = 6.0,
|
|
127
|
+
opacity: float = 0.85,
|
|
128
|
+
shape: str = "circle",
|
|
129
|
+
stroke: str = "",
|
|
130
|
+
stroke_width: float = 1.0,
|
|
131
|
+
size_by: Any = None,
|
|
132
|
+
size_range: list[float] | None = None,
|
|
133
|
+
color_by: Any = None,
|
|
134
|
+
color_scale: list[str] | None = None,
|
|
135
|
+
axis: str = "left",
|
|
136
|
+
trendline: bool = False,
|
|
137
|
+
trendline_color: str = "",
|
|
138
|
+
trendline_width: float = 1.5,
|
|
139
|
+
trendline_dash: bool = True,
|
|
140
|
+
hover_format: str = "",
|
|
141
|
+
hover_unit: str = "",
|
|
142
|
+
hover_x_label: str = "x",
|
|
143
|
+
hover_y_label: str = "y",
|
|
144
|
+
labels: Any = None,
|
|
145
|
+
label_font_size: int = 9,
|
|
146
|
+
label_color: str = "",
|
|
147
|
+
jitter_x: float = 0.0,
|
|
148
|
+
jitter_y: float = 0.0,
|
|
149
|
+
) -> "Figure":
|
|
150
|
+
validate_xy(x, y, label)
|
|
151
|
+
resolved_color = resolve(color) if color else self._color_cycle.next()
|
|
152
|
+
s = ScatterSeries(
|
|
153
|
+
x=x, y=y, label=label,
|
|
154
|
+
color=resolved_color, size=size, opacity=opacity,
|
|
155
|
+
shape=shape, stroke=stroke, stroke_width=stroke_width,
|
|
156
|
+
size_by=size_by, size_range=size_range or [3.0, 18.0],
|
|
157
|
+
color_by=color_by, color_scale=color_scale or ["#6366f1", "#f43f5e"],
|
|
158
|
+
axis=axis,
|
|
159
|
+
trendline=trendline, trendline_color=trendline_color,
|
|
160
|
+
trendline_width=trendline_width, trendline_dash=trendline_dash,
|
|
161
|
+
hover_format=hover_format, hover_unit=hover_unit,
|
|
162
|
+
hover_x_label=hover_x_label, hover_y_label=hover_y_label,
|
|
163
|
+
labels=labels, label_font_size=label_font_size, label_color=label_color,
|
|
164
|
+
jitter_x=jitter_x, jitter_y=jitter_y,
|
|
165
|
+
)
|
|
166
|
+
idx = len(self._scatter_series)
|
|
167
|
+
self._scatter_series.append(s)
|
|
168
|
+
self._series_order.append(("scatter", idx))
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
# ── Annotations ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def vline(self, x, label="", color="", width=1.5, dash=True) -> "Figure":
|
|
174
|
+
c = resolve(color) if color else self.theme.vline_color
|
|
175
|
+
self._vlines.append(VLine(x=x, label=label, color=c, width=width, dash=dash))
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def hline(self, y, label="", color="", width=1.5, dash=True, scale="left") -> "Figure":
|
|
179
|
+
c = resolve(color) if color else self.theme.hline_color
|
|
180
|
+
self._hlines.append(HLine(y=y, label=label, color=c, width=width, dash=dash, scale=scale))
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def region(self, x_start, x_end, label="", color="indigo", opacity=0.08) -> "Figure":
|
|
184
|
+
c = resolve(color) if color else self.theme.region_color
|
|
185
|
+
self._regions.append(Region(x_start=x_start, x_end=x_end, label=label, color=c, opacity=opacity))
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def tag(
|
|
189
|
+
self,
|
|
190
|
+
x_start,
|
|
191
|
+
x_end,
|
|
192
|
+
label: str = "Tag",
|
|
193
|
+
color: str = "#FF00FF",
|
|
194
|
+
opacity: float = 0.05,
|
|
195
|
+
removable: bool = True,
|
|
196
|
+
) -> "Figure":
|
|
197
|
+
"""Add a named tag region via code (visible without interaction mode).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
x_start: Start x value (unix ms for timeseries).
|
|
201
|
+
x_end: End x value (unix ms for timeseries).
|
|
202
|
+
label: Tag name shown in the header band and the bubble list.
|
|
203
|
+
color: Hex colour for the tag (defaults to magenta).
|
|
204
|
+
opacity: Fill opacity of the shaded region (default 0.05).
|
|
205
|
+
removable: If False the tag cannot be deleted from the UI.
|
|
206
|
+
"""
|
|
207
|
+
c = resolve(color) if color else "#FF00FF"
|
|
208
|
+
import time as _time
|
|
209
|
+
tag_id = f"tag_code_{int(_time.time()*1000)}_{len(self._regions)}"
|
|
210
|
+
r = Region(x_start=x_start, x_end=x_end, label=label, color=c, opacity=opacity)
|
|
211
|
+
r._tagId = tag_id # marks it as a tag so the header band is drawn
|
|
212
|
+
r._removable = removable # passed through to JS
|
|
213
|
+
self._regions.append(r)
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def band(
|
|
217
|
+
self,
|
|
218
|
+
y_lo: float,
|
|
219
|
+
y_hi: float,
|
|
220
|
+
label: str = "",
|
|
221
|
+
color: str = "indigo",
|
|
222
|
+
opacity: float = 0.12,
|
|
223
|
+
axis: str = "left",
|
|
224
|
+
) -> "Figure":
|
|
225
|
+
"""Add a horizontal threshold band between y_lo and y_hi.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
y_lo: Lower Y bound.
|
|
229
|
+
y_hi: Upper Y bound.
|
|
230
|
+
label: Label drawn at top-left of band.
|
|
231
|
+
color: Fill colour (named or hex).
|
|
232
|
+
opacity: Fill opacity (default 0.12).
|
|
233
|
+
axis: 'left' or 'right' — which Y axis scale to use.
|
|
234
|
+
"""
|
|
235
|
+
c = resolve(color) if color else "#6366f1"
|
|
236
|
+
scale = "y" if axis == "left" else "y2"
|
|
237
|
+
self._bands.append(Band(y_lo=y_lo, y_hi=y_hi, label=label, color=c, opacity=opacity, scale=scale))
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
def pin(
|
|
241
|
+
self,
|
|
242
|
+
x,
|
|
243
|
+
label: str = "",
|
|
244
|
+
y_frac: float = 0.2,
|
|
245
|
+
color: str = "",
|
|
246
|
+
y: float | None = None,
|
|
247
|
+
scale: str = "left",
|
|
248
|
+
) -> "Figure":
|
|
249
|
+
"""Add an annotation pin at a specific x position.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
x: X position — unix ms for timeseries, numeric otherwise.
|
|
253
|
+
label: Text shown on the sticky note and pin popup.
|
|
254
|
+
y_frac: Vertical position as a fraction of plot height (0 = top, 1 = bottom).
|
|
255
|
+
color: Pin colour. Leave empty to auto-cycle through the palette.
|
|
256
|
+
|
|
257
|
+
Example::
|
|
258
|
+
|
|
259
|
+
fig.pin(ts_ms[300], label="Anomaly detected", y_frac=0.3)
|
|
260
|
+
fig.pin(ts_ms[600], label="System restart", color="#f43f5e")
|
|
261
|
+
"""
|
|
262
|
+
self._pins.append(Pin(x=x, label=label, y_frac=y_frac, color=color, y=y, scale=scale))
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
# ── Render ────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def to_html(self) -> str:
|
|
268
|
+
"""Render the figure to a self-contained HTML string."""
|
|
269
|
+
from youplot.render.html import build_html
|
|
270
|
+
return build_html(**self._render_kwargs())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def to_fragment(self) -> str:
|
|
274
|
+
"""
|
|
275
|
+
Render as an HTML fragment (no <html>/<head>/<body>).
|
|
276
|
+
Used for embedding multiple charts on one page via combine().
|
|
277
|
+
The caller must include the uPlot CDN script once.
|
|
278
|
+
"""
|
|
279
|
+
from youplot.render.html import chart_fragment
|
|
280
|
+
return self._render(chart_fragment)
|
|
281
|
+
|
|
282
|
+
def _render(self, render_fn):
|
|
283
|
+
"""Shared render logic — calls either build_html or chart_fragment."""
|
|
284
|
+
from youplot.render.html import build_html
|
|
285
|
+
return render_fn(**self._render_kwargs())
|
|
286
|
+
|
|
287
|
+
def _render_kwargs(self):
|
|
288
|
+
"""Build the kwargs dict for build_html / chart_fragment."""
|
|
289
|
+
all_series = self._line_series + self._scatter_series
|
|
290
|
+
if not all_series:
|
|
291
|
+
raise ValueError("No series added.")
|
|
292
|
+
|
|
293
|
+
first_x = all_series[0].x
|
|
294
|
+
is_timeseries = _is_timeseries(first_x)
|
|
295
|
+
|
|
296
|
+
x_ms_per_line, y_per_line = [], []
|
|
297
|
+
for s in self._line_series:
|
|
298
|
+
x_ms = to_timestamp_ms_list(s.x) if is_timeseries else [int(v*1000) for v in to_float_list(s.x) if v is not None]
|
|
299
|
+
y = apply_null_handling(to_float_list(s.y), s.null_handling)
|
|
300
|
+
if s.gap_threshold is not None:
|
|
301
|
+
x_ms, y = inject_gaps(x_ms, y, s.gap_threshold)
|
|
302
|
+
x_ms_per_line.append(x_ms)
|
|
303
|
+
y_per_line.append(y)
|
|
304
|
+
|
|
305
|
+
# Ensure pins have primitive serializable types
|
|
306
|
+
for p in self._pins:
|
|
307
|
+
if is_timeseries:
|
|
308
|
+
if isinstance(p.x, (datetime, date, str)) or str(type(p.x)).find('Timestamp') > -1:
|
|
309
|
+
p.x = to_timestamp_ms_list([p.x])[0]
|
|
310
|
+
elif isinstance(p.x, (datetime, date)):
|
|
311
|
+
p.x = p.x.timestamp() * 1000
|
|
312
|
+
|
|
313
|
+
if isinstance(p.y, (datetime, date)):
|
|
314
|
+
p.y = p.y.timestamp() * 1000
|
|
315
|
+
|
|
316
|
+
scale_map: dict[str, str] = {}
|
|
317
|
+
right_scales: set[str] = set()
|
|
318
|
+
scale_names: list[str] = []
|
|
319
|
+
for s in all_series:
|
|
320
|
+
if s.axis not in scale_map:
|
|
321
|
+
scale_name = "y" if s.axis == "left" else "y2"
|
|
322
|
+
scale_map[s.axis] = scale_name
|
|
323
|
+
scale_names.append(scale_name)
|
|
324
|
+
if s.axis == "right":
|
|
325
|
+
right_scales.add(scale_name)
|
|
326
|
+
s._scale_name = scale_map[s.axis]
|
|
327
|
+
|
|
328
|
+
range_map = {}
|
|
329
|
+
for axis, scale_name in scale_map.items():
|
|
330
|
+
range_map[scale_name] = self.y_right_range if axis == "right" else self.y_range
|
|
331
|
+
|
|
332
|
+
x_range_s = None
|
|
333
|
+
if self.x_range:
|
|
334
|
+
x_range_s = [self.x_range[0]/1000, self.x_range[1]/1000] if is_timeseries else self.x_range
|
|
335
|
+
|
|
336
|
+
return dict(
|
|
337
|
+
line_series=self._line_series,
|
|
338
|
+
scatter_series=self._scatter_series,
|
|
339
|
+
series_order=self._series_order,
|
|
340
|
+
x_ms_per_line=x_ms_per_line,
|
|
341
|
+
y_per_line=y_per_line,
|
|
342
|
+
theme=self.theme,
|
|
343
|
+
title=self.title,
|
|
344
|
+
subtitle=self.subtitle,
|
|
345
|
+
width=self.width,
|
|
346
|
+
height=self.height,
|
|
347
|
+
x_label=self.x_label,
|
|
348
|
+
y_label=self.y_label,
|
|
349
|
+
y_right_label=self.y_right_label,
|
|
350
|
+
x_format=self.x_format,
|
|
351
|
+
y_format=self.y_format,
|
|
352
|
+
x_range=x_range_s,
|
|
353
|
+
range_map=range_map,
|
|
354
|
+
zoom=self.zoom,
|
|
355
|
+
legend=self.legend,
|
|
356
|
+
scale_names=scale_names,
|
|
357
|
+
right_scales=right_scales,
|
|
358
|
+
vlines=self._vlines,
|
|
359
|
+
hlines=self._hlines,
|
|
360
|
+
regions=self._regions,
|
|
361
|
+
bands=self._bands,
|
|
362
|
+
pins=self._pins,
|
|
363
|
+
is_timeseries=is_timeseries,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def show(self) -> None:
|
|
367
|
+
_show(self.to_html())
|
|
368
|
+
|
|
369
|
+
def save(self, path: str) -> str:
|
|
370
|
+
return _save(self.to_html(), path)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _is_timeseries(x: Any) -> bool:
|
|
374
|
+
try:
|
|
375
|
+
import pandas as pd, numpy as np
|
|
376
|
+
if isinstance(x, pd.Series):
|
|
377
|
+
if pd.api.types.is_datetime64_any_dtype(x): return True
|
|
378
|
+
x = x.values
|
|
379
|
+
if isinstance(x, np.ndarray) and np.issubdtype(x.dtype, np.datetime64): return True
|
|
380
|
+
except ImportError:
|
|
381
|
+
pass
|
|
382
|
+
try:
|
|
383
|
+
first = x[0]
|
|
384
|
+
except (IndexError, KeyError, TypeError):
|
|
385
|
+
return False
|
|
386
|
+
try:
|
|
387
|
+
from datetime import datetime
|
|
388
|
+
if isinstance(first, datetime): return True
|
|
389
|
+
except ImportError:
|
|
390
|
+
pass
|
|
391
|
+
try:
|
|
392
|
+
import pandas as pd
|
|
393
|
+
if isinstance(first, pd.Timestamp): return True
|
|
394
|
+
except ImportError:
|
|
395
|
+
pass
|
|
396
|
+
try:
|
|
397
|
+
return float(first) > 1e9
|
|
398
|
+
except (TypeError, ValueError):
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class Dashboard:
|
|
403
|
+
"""A collection of figures rendered together with shared crosshair sync."""
|
|
404
|
+
|
|
405
|
+
def __init__(self, title: str = "", theme: str = "light"):
|
|
406
|
+
self.title = title
|
|
407
|
+
self.theme = theme
|
|
408
|
+
self._figs: list[Figure] = []
|
|
409
|
+
|
|
410
|
+
def add(self, fig: "Figure") -> "Dashboard":
|
|
411
|
+
"""Add a figure to the dashboard. Returns self for chaining."""
|
|
412
|
+
self._figs.append(fig)
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def __add__(self, other: "Figure") -> "Dashboard":
|
|
416
|
+
"""Support: dashboard = fig1 + fig2 or dashboard + fig3."""
|
|
417
|
+
if isinstance(other, Figure):
|
|
418
|
+
self.add(other)
|
|
419
|
+
elif isinstance(other, Dashboard):
|
|
420
|
+
for f in other._figs:
|
|
421
|
+
self.add(f)
|
|
422
|
+
return self
|
|
423
|
+
|
|
424
|
+
def to_html(self) -> str:
|
|
425
|
+
"""Render all figures into a single standalone HTML page."""
|
|
426
|
+
from youplot.render.html import chart_fragment_parts
|
|
427
|
+
from youplot.render.css import build_css
|
|
428
|
+
from youplot.render.js import UPLOT_CDN, UPLOT_CSS
|
|
429
|
+
from youplot.themes.base import get as get_theme
|
|
430
|
+
|
|
431
|
+
theme = get_theme(self.theme)
|
|
432
|
+
css = build_css(theme)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
from youplot.vendor import uplot_js as _ujs, uplot_css as _ucss
|
|
436
|
+
uplot_head = f'<style>{_ucss()}</style><script>{_ujs()}</script>'
|
|
437
|
+
except Exception:
|
|
438
|
+
uplot_head = (
|
|
439
|
+
f'<link rel="stylesheet" href="{UPLOT_CSS}">'
|
|
440
|
+
f'<script src="{UPLOT_CDN}"></script>'
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Collect CSS separately from body so everything lands in <head>
|
|
444
|
+
all_css = [css]
|
|
445
|
+
all_body = []
|
|
446
|
+
all_scripts = []
|
|
447
|
+
for fig in self._figs:
|
|
448
|
+
fig_css, fig_body, fig_script = chart_fragment_parts(**fig._render_kwargs())
|
|
449
|
+
all_css.append(fig_css)
|
|
450
|
+
all_body.append(fig_body)
|
|
451
|
+
all_scripts.append(fig_script)
|
|
452
|
+
|
|
453
|
+
title_html = f'<div class="up-dash-title">{self.title}</div>' if self.title else ''
|
|
454
|
+
extra_css = (
|
|
455
|
+
f'.up-dash-title{{font-size:16px;font-weight:600;letter-spacing:-0.02em;'
|
|
456
|
+
f'color:{theme.text_title};padding:20px 28px 0;}}'
|
|
457
|
+
)
|
|
458
|
+
all_css.append(extra_css)
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
"<!DOCTYPE html><html lang='en'><head>"
|
|
462
|
+
'<meta charset="UTF-8">'
|
|
463
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1.0">'
|
|
464
|
+
+ uplot_head
|
|
465
|
+
+ f'<title>{self.title or "youplot"}</title>'
|
|
466
|
+
+ ''.join(f'<style>{c}</style>' for c in all_css)
|
|
467
|
+
+ '</head><body>'
|
|
468
|
+
+ title_html
|
|
469
|
+
+ '\n'.join(all_body)
|
|
470
|
+
+ '\n'.join(all_scripts)
|
|
471
|
+
+ '</body></html>'
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def show(self) -> None:
|
|
475
|
+
from youplot.utils.browser import show as _show
|
|
476
|
+
_show(self.to_html())
|
|
477
|
+
|
|
478
|
+
def save(self, path: str) -> str:
|
|
479
|
+
from youplot.utils.browser import save as _save
|
|
480
|
+
return _save(self.to_html(), path)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def combine(*figs, title: str = "", theme: str = "light") -> Dashboard:
|
|
484
|
+
"""Combine multiple figures into a synced dashboard.
|
|
485
|
+
|
|
486
|
+
Usage::
|
|
487
|
+
|
|
488
|
+
dash = up.combine(fig1, fig2, fig3, title="My Dashboard")
|
|
489
|
+
dash.show()
|
|
490
|
+
dash.save("out.html")
|
|
491
|
+
|
|
492
|
+
Or using + operator::
|
|
493
|
+
|
|
494
|
+
dash = fig1 + fig2
|
|
495
|
+
dash.show()
|
|
496
|
+
"""
|
|
497
|
+
db = Dashboard(title=title, theme=theme)
|
|
498
|
+
for f in figs:
|
|
499
|
+
if isinstance(f, Figure):
|
|
500
|
+
db.add(f)
|
|
501
|
+
elif isinstance(f, Dashboard):
|
|
502
|
+
for inner in f._figs:
|
|
503
|
+
db.add(inner)
|
|
504
|
+
return db
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# Allow fig1 + fig2 to produce a Dashboard directly
|
|
508
|
+
def _figure_add(self, other):
|
|
509
|
+
db = Dashboard()
|
|
510
|
+
db.add(self)
|
|
511
|
+
if isinstance(other, Figure):
|
|
512
|
+
db.add(other)
|
|
513
|
+
elif isinstance(other, Dashboard):
|
|
514
|
+
for f in other._figs:
|
|
515
|
+
db.add(f)
|
|
516
|
+
return db
|
|
517
|
+
|
|
518
|
+
Figure.__add__ = _figure_add
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Annotation types — overlays drawn on top of the chart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class VLine:
|
|
9
|
+
"""Vertical line at a specific x value (timestamp or numeric)."""
|
|
10
|
+
x: float | int
|
|
11
|
+
label: str = ""
|
|
12
|
+
color: str = "#94a3b8"
|
|
13
|
+
width: float = 1.5
|
|
14
|
+
dash: bool = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class HLine:
|
|
19
|
+
"""Horizontal reference line at a specific y value."""
|
|
20
|
+
y: float
|
|
21
|
+
label: str = ""
|
|
22
|
+
color: str = "#94a3b8"
|
|
23
|
+
width: float = 1.5
|
|
24
|
+
dash: bool = True
|
|
25
|
+
scale: str = "left"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Region:
|
|
30
|
+
"""Shaded vertical band between x_start and x_end."""
|
|
31
|
+
x_start: float | int
|
|
32
|
+
x_end: float | int
|
|
33
|
+
label: str = ""
|
|
34
|
+
color: str = "#6366f1"
|
|
35
|
+
opacity: float = 0.08
|
|
36
|
+
_tagId: str = ""
|
|
37
|
+
_removable: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Band:
|
|
42
|
+
"""Horizontal shaded band between y_lo and y_hi (threshold zone)."""
|
|
43
|
+
y_lo: float
|
|
44
|
+
y_hi: float
|
|
45
|
+
label: str = ""
|
|
46
|
+
color: str = "#6366f1"
|
|
47
|
+
opacity: float = 0.12
|
|
48
|
+
scale: str = "left"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Pin:
|
|
53
|
+
"""Annotation pin dropped on the chart at a specific x position."""
|
|
54
|
+
x: float | int # unix ms for timeseries, numeric otherwise
|
|
55
|
+
label: str = ""
|
|
56
|
+
y_frac: float = 0.2 # vertical position as fraction of plot height (0=top, 1=bottom)
|
|
57
|
+
color: str = "" # empty = auto-assigned from palette
|
|
58
|
+
y: float | None = None
|
|
59
|
+
scale: str = "left"
|
|
60
|
+
|
youplot/options/axes.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Axis and Scale configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AxisOptions:
|
|
9
|
+
label: str = ""
|
|
10
|
+
format: str = "" # tick format string e.g. "%d %b", ".2f"
|
|
11
|
+
range: list | None = None # [min, max] — None = auto
|
|
12
|
+
ticks: int | list | None = None
|
|
13
|
+
tick_size: int = 11
|
|
14
|
+
side: str = "left" # "left" or "right"
|
|
15
|
+
show: bool = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ScaleOptions:
|
|
20
|
+
name: str = "" # internal scale id
|
|
21
|
+
range: list | None = None
|
|
22
|
+
auto: bool = True
|