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/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>'