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/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
+
@@ -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