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/render/html.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""HTML renderer — handles line, scatter, and mixed charts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import uuid
|
|
5
|
+
from youplot.render.css import build_css
|
|
6
|
+
from youplot.render.js import UPLOT_CDN, UPLOT_CSS, JS
|
|
7
|
+
from youplot.render.scatter_js import SCATTER_JS
|
|
8
|
+
from youplot.render import serializer as ser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_internals(
|
|
12
|
+
line_series, scatter_series, series_order,
|
|
13
|
+
x_ms_per_line, y_per_line,
|
|
14
|
+
theme, title, subtitle, width, height,
|
|
15
|
+
x_label, y_label, y_right_label,
|
|
16
|
+
x_format, y_format,
|
|
17
|
+
x_range, range_map,
|
|
18
|
+
zoom, legend,
|
|
19
|
+
scale_names, right_scales,
|
|
20
|
+
vlines, hlines, regions, bands, pins,
|
|
21
|
+
is_timeseries,
|
|
22
|
+
cid,
|
|
23
|
+
):
|
|
24
|
+
"""Core render logic shared by build_html and chart_fragment."""
|
|
25
|
+
container_id = f"up-chart-{cid}"
|
|
26
|
+
tooltip_id = f"up-tooltip-{cid}"
|
|
27
|
+
reset_fn = f"upReset_{cid}"
|
|
28
|
+
|
|
29
|
+
css = build_css(theme)
|
|
30
|
+
has_scatter = len(scatter_series) > 0
|
|
31
|
+
has_line = len(line_series) > 0
|
|
32
|
+
|
|
33
|
+
# X data
|
|
34
|
+
if has_line:
|
|
35
|
+
x_ms = x_ms_per_line[0]
|
|
36
|
+
x_decl = ser.timestamps_to_js_array(x_ms, "UP_X_DATA")
|
|
37
|
+
x_len = len(x_ms)
|
|
38
|
+
else:
|
|
39
|
+
from youplot.utils.data import to_float_list
|
|
40
|
+
x_raw = [v for v in to_float_list(scatter_series[0].x) if v is not None]
|
|
41
|
+
x_decl = ser.numeric_to_js_array(x_raw, "UP_X_DATA")
|
|
42
|
+
x_len = len(x_raw)
|
|
43
|
+
|
|
44
|
+
# Y data
|
|
45
|
+
y_decls, y_var_names = [], []
|
|
46
|
+
for i, y in enumerate(y_per_line):
|
|
47
|
+
name = f"UP_Y_{i}"
|
|
48
|
+
y_var_names.append(name)
|
|
49
|
+
y_decls.append(ser.series_to_js_array(y, name))
|
|
50
|
+
|
|
51
|
+
if not has_line and has_scatter:
|
|
52
|
+
y_decls.append(ser.series_to_js_array([None] * x_len, "UP_Y_0"))
|
|
53
|
+
y_var_names.append("UP_Y_0")
|
|
54
|
+
|
|
55
|
+
# uPlot series
|
|
56
|
+
if has_line:
|
|
57
|
+
uplot_series = list(line_series)
|
|
58
|
+
else:
|
|
59
|
+
from youplot.series.line import LineSeries
|
|
60
|
+
dummy_s = LineSeries(x=[], y=[], label="", color="rgba(0,0,0,0)", width=0)
|
|
61
|
+
dummy_s._scale_name = scatter_series[0]._scale_name
|
|
62
|
+
uplot_series = [dummy_s]
|
|
63
|
+
|
|
64
|
+
series_cfg_js = ser.series_config_to_js(uplot_series)
|
|
65
|
+
scales_cfg_js = ser.scales_config_to_js(scale_names, right_scales, x_range, range_map, is_timeseries)
|
|
66
|
+
axes_cfg_js = ser.axes_config_to_js(
|
|
67
|
+
scale_names, right_scales, theme,
|
|
68
|
+
x_label, y_label, y_right_label, x_format, y_format, is_timeseries
|
|
69
|
+
)
|
|
70
|
+
ann_js = ser.annotations_to_js(vlines, hlines, regions)
|
|
71
|
+
|
|
72
|
+
scatter_decls_js, scatter_cfg_js = "", "[]"
|
|
73
|
+
if has_scatter:
|
|
74
|
+
scatter_decls_js, scatter_cfg_js = ser.scatter_configs_to_js(
|
|
75
|
+
scatter_series, draw_si=1, is_timeseries=is_timeseries
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Legend
|
|
79
|
+
legend_html = ""
|
|
80
|
+
if legend:
|
|
81
|
+
items = []
|
|
82
|
+
for kind, idx in series_order:
|
|
83
|
+
if kind == "line":
|
|
84
|
+
s = line_series[idx]
|
|
85
|
+
si = idx + 1
|
|
86
|
+
dash_cls = " dashed" if s.resolved_dash() else ""
|
|
87
|
+
items.append(
|
|
88
|
+
f'<span class="up-leg-item" data-si="{si}" data-kind="line" '
|
|
89
|
+
f'title="Click toggle · Double-click isolate">'
|
|
90
|
+
f'<span class="up-leg-swatch{dash_cls}" style="background:{s.color};color:{s.color}"></span>'
|
|
91
|
+
f'<span class="up-leg-label">{s.label or f"Series {si}"}</span></span>'
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
s = scatter_series[idx]
|
|
95
|
+
items.append(
|
|
96
|
+
f'<span class="up-leg-item" data-si="sc-{idx}" data-kind="scatter" '
|
|
97
|
+
f'title="Click toggle · Double-click isolate">'
|
|
98
|
+
f'{_shape_svg(s.shape, s.color)}'
|
|
99
|
+
f'<span class="up-leg-label">{s.label or f"Scatter {idx+1}"}</span></span>'
|
|
100
|
+
)
|
|
101
|
+
legend_html = '<div class="up-legend">' + "".join(items) + '</div>'
|
|
102
|
+
|
|
103
|
+
toolbar_html = ""
|
|
104
|
+
if zoom:
|
|
105
|
+
toolbar_html = (
|
|
106
|
+
'<div class="up-toolbar">'
|
|
107
|
+
'<div class="up-tools">'
|
|
108
|
+
f'<button class="up-btn up-tool-btn active" data-tool="zoom" id="up-tool-zoom-{cid}" title="Drag to zoom · Right-drag to zoom Y · Dbl-click to reset">Zoom</button>'
|
|
109
|
+
+ ('' if has_scatter and not has_line else
|
|
110
|
+
f'<button class="up-btn up-tool-btn" data-tool="tag" id="up-tool-tag-{cid}" title="Drag to create a named region tag">Tag</button>'
|
|
111
|
+
f'<button class="up-btn up-tool-btn" data-tool="measure" id="up-tool-measure-{cid}" title="Click anchor · right-click to save measurement">Measure</button>')
|
|
112
|
+
+ f'<button class="up-btn up-tool-btn" data-tool="annotate" id="up-tool-annotate-{cid}" title="Click to drop an annotation pin">Annotate</button>'
|
|
113
|
+
'</div>'
|
|
114
|
+
'<div class="up-toolbar-right">'
|
|
115
|
+
f'<button class="up-btn up-export-btn" title="Export current state as HTML">⬇ Export</button>'
|
|
116
|
+
f'<button class="up-btn" onclick="{reset_fn}()">Reset zoom</button>'
|
|
117
|
+
'</div>'
|
|
118
|
+
'</div>'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
prompt_html = (
|
|
122
|
+
f'<div id="up-tag-prompt-{cid}" class="up-tag-prompt">'
|
|
123
|
+
'<div class="up-tag-prompt-box">'
|
|
124
|
+
'<div class="up-tag-prompt-title">Name this region</div>'
|
|
125
|
+
f'<input type="text" id="up-tag-input-{cid}" class="up-tag-input" placeholder="Tag name"/>'
|
|
126
|
+
'<div class="up-tag-prompt-actions">'
|
|
127
|
+
f'<button class="up-btn" id="up-tag-cancel-{cid}">Cancel</button>'
|
|
128
|
+
f'<button class="up-btn up-btn-primary" id="up-tag-save-{cid}">Save</button>'
|
|
129
|
+
'</div>'
|
|
130
|
+
'</div></div>'
|
|
131
|
+
)
|
|
132
|
+
ann_prompt_html = (
|
|
133
|
+
f'<div id="up-ann-prompt-{cid}" class="up-ann-prompt">'
|
|
134
|
+
'<div class="up-tag-prompt-box">'
|
|
135
|
+
'<div class="up-tag-prompt-title">Add annotation</div>'
|
|
136
|
+
f'<input type="text" id="up-ann-input-{cid}" class="up-tag-input up-ann-input" placeholder="Annotation text"/>'
|
|
137
|
+
'<div class="up-tag-prompt-actions">'
|
|
138
|
+
f'<button class="up-btn" id="up-ann-cancel-{cid}">Cancel</button>'
|
|
139
|
+
f'<button class="up-btn up-btn-primary" id="up-ann-save-{cid}">Add pin</button>'
|
|
140
|
+
'</div>'
|
|
141
|
+
'</div></div>'
|
|
142
|
+
)
|
|
143
|
+
pins_layer_html = f'<div class="up-pins-layer" id="up-pins-{cid}"></div>'
|
|
144
|
+
tags_list_html = f'<div id="up-tags-{cid}" class="up-tags-list"></div>'
|
|
145
|
+
|
|
146
|
+
header_html = ""
|
|
147
|
+
if title or subtitle:
|
|
148
|
+
header_html = '<div class="up-header">'
|
|
149
|
+
if title: header_html += f'<div class="up-title">{title}</div>'
|
|
150
|
+
if subtitle: header_html += f'<div class="up-subtitle">{subtitle}</div>'
|
|
151
|
+
header_html += '</div>'
|
|
152
|
+
|
|
153
|
+
w_style = f"width:{width}px" if isinstance(width, int) else f"width:{width}"
|
|
154
|
+
|
|
155
|
+
js_data = "\n".join([
|
|
156
|
+
f'const UP_CONTAINER_ID = "{container_id}";',
|
|
157
|
+
f'const UP_TOOLTIP_ID = "{tooltip_id}";',
|
|
158
|
+
f'const UP_RESET_FN = "{reset_fn}";',
|
|
159
|
+
x_decl, *y_decls,
|
|
160
|
+
f"const UP_Y_DATA = [{','.join(y_var_names)}];",
|
|
161
|
+
f"const UP_SERIES_CFG = {series_cfg_js};",
|
|
162
|
+
f"const UP_SCALES = {scales_cfg_js};",
|
|
163
|
+
f"const UP_AXES = {axes_cfg_js};",
|
|
164
|
+
f"const UP_HEIGHT = {height};",
|
|
165
|
+
f"const UP_IS_TIMESERIES = {'true' if is_timeseries else 'false'};",
|
|
166
|
+
f"const UP_HAS_SCATTER = {'true' if has_scatter else 'false'};",
|
|
167
|
+
scatter_decls_js,
|
|
168
|
+
f"const UP_SCATTER_CFG = {scatter_cfg_js};",
|
|
169
|
+
f"const UP_INITIAL_RANGES = {ser.initial_ranges_to_js(x_range, range_map, is_timeseries)};",
|
|
170
|
+
ann_js,
|
|
171
|
+
f"const UP_BANDS = {ser.bands_to_js(bands)};",
|
|
172
|
+
f"const UP_CODE_PINS = {ser.pins_to_js(pins)};",
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
measure_bar_html = (
|
|
176
|
+
f'<div id="up-measure-bar-{cid}" class="up-measure-bar">'
|
|
177
|
+
f'<div class="up-measure-details" id="up-measure-details-{cid}"></div>'
|
|
178
|
+
'</div>'
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
body_html = (
|
|
182
|
+
f'<div class="up-page"><div class="up-card" style="{w_style}">'
|
|
183
|
+
+ header_html + legend_html + toolbar_html
|
|
184
|
+
+ f'<div style="position:relative"><div id="{container_id}"></div>{pins_layer_html}</div>'
|
|
185
|
+
+ measure_bar_html
|
|
186
|
+
+ tags_list_html
|
|
187
|
+
+ f'<div id="{tooltip_id}" class="up-tooltip"></div>'
|
|
188
|
+
+ prompt_html
|
|
189
|
+
+ ann_prompt_html
|
|
190
|
+
+ '</div></div>'
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Each chart gets its own IIFE so variables don't clash across charts
|
|
194
|
+
script = (
|
|
195
|
+
(f'<script>{SCATTER_JS}</script>' if has_scatter else '')
|
|
196
|
+
+ f'<script>(function(){{\n{js_data}\n{JS}\n}})()</script>'
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return css, body_html, script, has_scatter
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_html(
|
|
203
|
+
line_series, scatter_series, series_order,
|
|
204
|
+
x_ms_per_line, y_per_line,
|
|
205
|
+
theme, title, subtitle, width, height,
|
|
206
|
+
x_label, y_label, y_right_label,
|
|
207
|
+
x_format, y_format,
|
|
208
|
+
x_range, range_map,
|
|
209
|
+
zoom, legend,
|
|
210
|
+
scale_names, right_scales,
|
|
211
|
+
vlines, hlines, regions, bands, pins,
|
|
212
|
+
is_timeseries,
|
|
213
|
+
) -> str:
|
|
214
|
+
"""Full standalone HTML page for a single chart."""
|
|
215
|
+
cid = "upc" + uuid.uuid4().hex[:8]
|
|
216
|
+
css, body_html, script, _ = _build_internals(
|
|
217
|
+
line_series, scatter_series, series_order,
|
|
218
|
+
x_ms_per_line, y_per_line,
|
|
219
|
+
theme, title, subtitle, width, height,
|
|
220
|
+
x_label, y_label, y_right_label,
|
|
221
|
+
x_format, y_format,
|
|
222
|
+
x_range, range_map,
|
|
223
|
+
zoom, legend,
|
|
224
|
+
scale_names, right_scales,
|
|
225
|
+
vlines, hlines, regions, bands, pins,
|
|
226
|
+
is_timeseries, cid,
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
from youplot.vendor import uplot_js as _uplot_js, uplot_css as _uplot_css
|
|
230
|
+
uplot_js_inline = f'<script>{_uplot_js()}</script>'
|
|
231
|
+
uplot_css_inline = f'<style>{_uplot_css()}</style>'
|
|
232
|
+
except Exception:
|
|
233
|
+
uplot_js_inline = f'<script src="{UPLOT_CDN}"></script>'
|
|
234
|
+
uplot_css_inline = f'<link rel="stylesheet" href="{UPLOT_CSS}">'
|
|
235
|
+
return (
|
|
236
|
+
"<!DOCTYPE html><html lang='en'><head>"
|
|
237
|
+
'<meta charset="UTF-8">'
|
|
238
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1.0">'
|
|
239
|
+
+ uplot_css_inline
|
|
240
|
+
+ uplot_js_inline
|
|
241
|
+
+ f'<title>{title or "youplot"}</title>'
|
|
242
|
+
f'<style>{css}</style>'
|
|
243
|
+
f'</head><body>{body_html}{script}</body></html>'
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def chart_fragment(
|
|
248
|
+
line_series, scatter_series, series_order,
|
|
249
|
+
x_ms_per_line, y_per_line,
|
|
250
|
+
theme, title, subtitle, width, height,
|
|
251
|
+
x_label, y_label, y_right_label,
|
|
252
|
+
x_format, y_format,
|
|
253
|
+
x_range, range_map,
|
|
254
|
+
zoom, legend,
|
|
255
|
+
scale_names, right_scales,
|
|
256
|
+
vlines, hlines, regions, bands, pins,
|
|
257
|
+
is_timeseries,
|
|
258
|
+
) -> str:
|
|
259
|
+
"""
|
|
260
|
+
HTML fragment only — no <html>/<head>/<body>.
|
|
261
|
+
Use this to embed multiple charts on one page.
|
|
262
|
+
The caller is responsible for including uPlot CDN once.
|
|
263
|
+
"""
|
|
264
|
+
cid = "upc" + uuid.uuid4().hex[:8]
|
|
265
|
+
css, body_html, script, _ = _build_internals(
|
|
266
|
+
line_series, scatter_series, series_order,
|
|
267
|
+
x_ms_per_line, y_per_line,
|
|
268
|
+
theme, title, subtitle, width, height,
|
|
269
|
+
x_label, y_label, y_right_label,
|
|
270
|
+
x_format, y_format,
|
|
271
|
+
x_range, range_map,
|
|
272
|
+
zoom, legend,
|
|
273
|
+
scale_names, right_scales,
|
|
274
|
+
vlines, hlines, regions, bands, pins,
|
|
275
|
+
is_timeseries, cid,
|
|
276
|
+
)
|
|
277
|
+
return f'<style>{css}</style>{body_html}{script}'
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def chart_fragment_parts(
|
|
281
|
+
line_series, scatter_series, series_order,
|
|
282
|
+
x_ms_per_line, y_per_line,
|
|
283
|
+
theme, title, subtitle, width, height,
|
|
284
|
+
x_label, y_label, y_right_label,
|
|
285
|
+
x_format, y_format,
|
|
286
|
+
x_range, range_map,
|
|
287
|
+
zoom, legend,
|
|
288
|
+
scale_names, right_scales,
|
|
289
|
+
vlines, hlines, regions, bands, pins,
|
|
290
|
+
is_timeseries,
|
|
291
|
+
) -> tuple:
|
|
292
|
+
"""Return (css, body_html, script) separately so Dashboard can put CSS in <head>."""
|
|
293
|
+
cid = "upc" + uuid.uuid4().hex[:8]
|
|
294
|
+
css, body_html, script, _ = _build_internals(
|
|
295
|
+
line_series, scatter_series, series_order,
|
|
296
|
+
x_ms_per_line, y_per_line,
|
|
297
|
+
theme, title, subtitle, width, height,
|
|
298
|
+
x_label, y_label, y_right_label,
|
|
299
|
+
x_format, y_format,
|
|
300
|
+
x_range, range_map,
|
|
301
|
+
zoom, legend,
|
|
302
|
+
scale_names, right_scales,
|
|
303
|
+
vlines, hlines, regions, bands, pins,
|
|
304
|
+
is_timeseries, cid,
|
|
305
|
+
)
|
|
306
|
+
return css, body_html, script
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _shape_svg(shape: str, color: str) -> str:
|
|
310
|
+
s, h = 10, 5
|
|
311
|
+
shapes = {
|
|
312
|
+
"circle": f'<circle cx="{h}" cy="{h}" r="{h-1}" fill="{color}" opacity="0.85"/>',
|
|
313
|
+
"square": f'<rect x="1" y="1" width="{s-2}" height="{s-2}" fill="{color}" opacity="0.85"/>',
|
|
314
|
+
"triangle": f'<polygon points="{h},1 {s-1},{s-1} 1,{s-1}" fill="{color}" opacity="0.85"/>',
|
|
315
|
+
"diamond": f'<polygon points="{h},1 {s-1},{h} {h},{s-1} 1,{h}" fill="{color}" opacity="0.85"/>',
|
|
316
|
+
"cross": f'<path d="M3,1h4v2h2v4h-2v2h-4v-2h-2v-4h2z" fill="{color}" opacity="0.85"/>',
|
|
317
|
+
"star": f'<polygon points="{h},1 6,4 9,4 7,6 8,9 5,7 2,9 3,6 1,4 4,4" fill="{color}" opacity="0.85"/>',
|
|
318
|
+
}
|
|
319
|
+
inner = shapes.get(shape, shapes["circle"])
|
|
320
|
+
return f'<svg width="{s}" height="{s}" viewBox="0 0 {s} {s}" style="flex-shrink:0;margin-right:2px">{inner}</svg>'
|