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.
@@ -0,0 +1,195 @@
1
+ """Scatter plot JS."""
2
+
3
+ SCATTER_JS = r"""
4
+ (function() {
5
+
6
+ function hexAlpha(hex,alpha){
7
+ if(!hex)return'rgba(128,128,128,'+alpha+')';
8
+ if(hex.startsWith('rgba')||hex.startsWith('rgb'))return hex;
9
+ var h=hex.replace('#','');
10
+ if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
11
+ var r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
12
+ return'rgba('+r+','+g+','+b+','+alpha+')';
13
+ }
14
+ function lerpColor(hex1,hex2,t){
15
+ var h1=hex1.replace('#',''),h2=hex2.replace('#','');
16
+ if(h1.length===3)h1=h1[0]+h1[0]+h1[1]+h1[1]+h1[2]+h1[2];
17
+ if(h2.length===3)h2=h2[0]+h2[0]+h2[1]+h2[1]+h2[2]+h2[2];
18
+ var r=Math.round(parseInt(h1.slice(0,2),16)*(1-t)+parseInt(h2.slice(0,2),16)*t);
19
+ var g=Math.round(parseInt(h1.slice(2,4),16)*(1-t)+parseInt(h2.slice(2,4),16)*t);
20
+ var b=Math.round(parseInt(h1.slice(4,6),16)*(1-t)+parseInt(h2.slice(4,6),16)*t);
21
+ return'rgb('+r+','+g+','+b+')';
22
+ }
23
+ function drawShape(ctx,shape,cx,cy,r){
24
+ ctx.beginPath();
25
+ switch(shape){
26
+ case'square':ctx.rect(cx-r,cy-r,r*2,r*2);break;
27
+ case'triangle':ctx.moveTo(cx,cy-r);ctx.lineTo(cx+r*0.866,cy+r*0.5);ctx.lineTo(cx-r*0.866,cy+r*0.5);ctx.closePath();break;
28
+ case'diamond':ctx.moveTo(cx,cy-r);ctx.lineTo(cx+r,cy);ctx.lineTo(cx,cy+r);ctx.lineTo(cx-r,cy);ctx.closePath();break;
29
+ case'cross':var t=r*0.32;ctx.rect(cx-r,cy-t,r*2,t*2);ctx.rect(cx-t,cy-r,t*2,r*2);break;
30
+ case'star':for(var i=0;i<10;i++){var a=(i*Math.PI/5)-Math.PI/2,rad=i%2===0?r:r*0.45;if(i===0)ctx.moveTo(cx+rad*Math.cos(a),cy+rad*Math.sin(a));else ctx.lineTo(cx+rad*Math.cos(a),cy+rad*Math.sin(a));}ctx.closePath();break;
31
+ default:ctx.arc(cx,cy,r,0,Math.PI*2);
32
+ }
33
+ }
34
+ function linearRegression(xs,ys){
35
+ var n=xs.length,sx=0,sy=0,sxy=0,sxx=0;
36
+ for(var i=0;i<n;i++){sx+=xs[i];sy+=ys[i];sxy+=xs[i]*ys[i];sxx+=xs[i]*xs[i];}
37
+ var slope=(n*sxy-sx*sy)/(n*sxx-sx*sx);
38
+ return{slope:slope,intercept:(sy-slope*sx)/n};
39
+ }
40
+ function rSquared(xs,ys,slope,intercept){
41
+ var ym=ys.reduce(function(a,b){return a+b;},0)/ys.length,ssTot=0,ssRes=0;
42
+ for(var i=0;i<xs.length;i++){ssTot+=Math.pow(ys[i]-ym,2);ssRes+=Math.pow(ys[i]-(slope*xs[i]+intercept),2);}
43
+ return ssTot===0?1:1-ssRes/ssTot;
44
+ }
45
+ function round2(v){return Math.round(v*100)/100;}
46
+ function fmtVal(v,fmt){if(!fmt)return round2(v);var m=fmt.match(/\.(\d+)f/);return m?v.toFixed(parseInt(m[1])):round2(v);}
47
+
48
+ function makeScatterPlugin(scatterConfigs, tooltipId, upRegions, isTimeseries) {
49
+ var hitMap = [];
50
+
51
+ return {
52
+ hooks: {
53
+ ready: [function(u) {
54
+ var el = document.getElementById(tooltipId);
55
+ if (!el) return;
56
+
57
+ // Identical pattern to line chart:
58
+ // mousemove on u.over → compute position → find hit → show/hide
59
+ u.over.addEventListener('mousemove', function(e) {
60
+ // mouse position relative to the plot area (same space as hitMap hx/hy)
61
+ var rect = u.over.getBoundingClientRect();
62
+ var mx = e.clientX - rect.left;
63
+ var my = e.clientY - rect.top;
64
+
65
+ // Find point whose drawn radius contains the mouse
66
+ var best = null;
67
+ var bestDist = Infinity;
68
+ for (var k = 0; k < hitMap.length; k++) {
69
+ var h = hitMap[k];
70
+ if (h.cfg.visible === false) continue;
71
+ var d = Math.sqrt(Math.pow(mx - h.hx, 2) + Math.pow(my - h.hy, 2));
72
+ // Only trigger if mouse is within the drawn point radius (+2px grace)
73
+ if (d <= h.r + 2 && d < bestDist) {
74
+ bestDist = d;
75
+ best = h;
76
+ }
77
+ }
78
+
79
+ if (!best) { el.style.display = 'none'; return; }
80
+
81
+ var cfg = best.cfg, unit = cfg.hoverUnit||'';
82
+ var html = '<div class="up-tooltip-time">'+(cfg.label||'')+'</div>';
83
+
84
+ if (upRegions) {
85
+ var tsVal = best.xv;
86
+ var xVal = isTimeseries ? tsVal * 1000 : tsVal;
87
+ upRegions.forEach(function(r){
88
+ if (r._tagId && r.label && xVal >= Math.min(r.x_start, r.x_end) && xVal <= Math.max(r.x_start, r.x_end)) {
89
+ html += '<div class="up-tooltip-row" style="background:'+hexAlpha(r.color, 0.15)+'; border-radius:4px; padding:3px 6px; margin-bottom:6px; border:1px solid '+hexAlpha(r.color,0.3)+';">'
90
+ + '<span class="up-tooltip-dot" style="background:'+r.color+'"></span>'
91
+ + '<span class="up-tooltip-name" style="font-weight:600; color:'+r.color+'; text-shadow:0 0 1px #000;">Tag: '+r.label+'</span>'
92
+ + '</div>';
93
+ }
94
+ });
95
+ }
96
+
97
+ html += '<div class="up-tooltip-row">'
98
+ +'<span class="up-tooltip-dot" style="background:'+best.fillColor+'"></span>'
99
+ +'<span class="up-tooltip-name">'+(cfg.hoverXLabel||'x')+'</span>'
100
+ +'<span class="up-tooltip-val" style="color:'+best.fillColor+'">'+fmtVal(best.xv,cfg.hoverFormat)+unit+'</span>'
101
+ +'</div>'
102
+ +'<div class="up-tooltip-row">'
103
+ +'<span class="up-tooltip-dot" style="background:'+best.fillColor+'"></span>'
104
+ +'<span class="up-tooltip-name">'+(cfg.hoverYLabel||'y')+'</span>'
105
+ +'<span class="up-tooltip-val" style="color:'+best.fillColor+'">'+fmtVal(best.yv,cfg.hoverFormat)+unit+'</span>'
106
+ +'</div>';
107
+ if(cfg._sizeData&&cfg._sizeData[best.i]!=null)
108
+ html+='<div class="up-tooltip-row"><span class="up-tooltip-name">size</span><span class="up-tooltip-val">'+round2(cfg._sizeData[best.i])+'</span></div>';
109
+ if(cfg._colorData&&cfg._colorData[best.i]!=null)
110
+ html+='<div class="up-tooltip-row"><span class="up-tooltip-name">value</span><span class="up-tooltip-val">'+round2(cfg._colorData[best.i])+'</span></div>';
111
+
112
+ el.innerHTML = html;
113
+ el.style.display = 'block';
114
+
115
+ // Position using e.clientX/Y — exactly like line chart
116
+ var tw = el.offsetWidth || 200;
117
+ var left = e.clientX + 18;
118
+ if (left + tw > window.innerWidth - 12) left = e.clientX - tw - 18;
119
+ var top = e.clientY - 10;
120
+ if (top + 140 > window.innerHeight) top = e.clientY - 140;
121
+ el.style.left = left + 'px';
122
+ el.style.top = top + 'px';
123
+ });
124
+
125
+ u.over.addEventListener('mouseleave', function() {
126
+ el.style.display = 'none';
127
+ });
128
+ }],
129
+
130
+ draw: [function(u) {
131
+ var ctx=u.ctx, b=u.bbox;
132
+ ctx.save();
133
+ ctx.beginPath();ctx.rect(b.left,b.top,b.width,b.height);ctx.clip();
134
+ hitMap = [];
135
+
136
+ scatterConfigs.forEach(function(scfg){
137
+ if(scfg.visible===false)return;
138
+ var xData=scfg._xData,yData=scfg._yData,sizeData=scfg._sizeData,colorData=scfg._colorData,labelData=scfg._labelData;
139
+ var scaleKey=scfg.scale||'y';
140
+ var sMin=Infinity,sMax=-Infinity;
141
+ if(sizeData){for(var i=0;i<sizeData.length;i++){if(sizeData[i]!=null&&!isNaN(sizeData[i])){sMin=Math.min(sMin,sizeData[i]);sMax=Math.max(sMax,sizeData[i]);}}}
142
+ var cMin=Infinity,cMax=-Infinity;
143
+ if(colorData){for(var i=0;i<colorData.length;i++){if(colorData[i]!=null&&!isNaN(colorData[i])){cMin=Math.min(cMin,colorData[i]);cMax=Math.max(cMax,colorData[i]);}}}
144
+
145
+ var validX=[],validY=[];
146
+ for(var i=0;i<xData.length;i++){
147
+ var xv=xData[i],yv=yData[i];
148
+ if(xv==null||isNaN(xv)||yv==null||isNaN(yv))continue;
149
+ // canvas-absolute coords
150
+ var cx=u.valToPos(xv,'x',true);
151
+ var cy=u.valToPos(yv,scaleKey,true);
152
+ var r=scfg.size;
153
+ if(sizeData&&sizeData[i]!=null&&!isNaN(sizeData[i])&&sMax>sMin){var st=(sizeData[i]-sMin)/(sMax-sMin);r=scfg.sizeRange[0]+st*(scfg.sizeRange[1]-scfg.sizeRange[0]);}
154
+ var fillColor=scfg.color;
155
+ if(colorData&&colorData[i]!=null&&!isNaN(colorData[i])&&cMax>cMin){var ct=(colorData[i]-cMin)/(cMax-cMin);fillColor=lerpColor(scfg.colorScale[0],scfg.colorScale[1],ct);}
156
+
157
+ ctx.globalAlpha=scfg.opacity;ctx.fillStyle=fillColor;
158
+ drawShape(ctx,scfg.shape,cx,cy,r);ctx.fill();
159
+ if(scfg.strokeWidth>0){ctx.globalAlpha=Math.min(1,scfg.opacity+0.15);ctx.strokeStyle=scfg.stroke||fillColor;ctx.lineWidth=scfg.strokeWidth;drawShape(ctx,scfg.shape,cx,cy,r);ctx.stroke();}
160
+ ctx.globalAlpha=1;
161
+ if(labelData&&labelData[i]!=null){ctx.fillStyle=hexAlpha(scfg.labelColor||scfg.color,0.9);ctx.font=scfg.labelFontSize+'px -apple-system,sans-serif';ctx.textAlign='left';ctx.fillText(String(labelData[i]),cx+r+3,cy+4);}
162
+
163
+ // hitMap stores plot-area-relative coords (matching mx/my from mousemove)
164
+ // mx = e.clientX - over.rect.left (over covers exactly the plot area in CSS pixels)
165
+ // hx = (cx - b.left) / pr (convert canvas-abs to CSS plot-area-relative)
166
+ var pr = window.devicePixelRatio || 1;
167
+ hitMap.push({si:scfg._scatterIdx,i:i,hx:(cx-b.left)/pr,hy:(cy-b.top)/pr,r:r,xv:xv,yv:yv,fillColor:fillColor,cfg:scfg});
168
+ validX.push(xv);validY.push(yv);
169
+ }
170
+
171
+ if(scfg.trendline&&validX.length>=2){
172
+ var reg=linearRegression(validX,validY);
173
+ var r2=rSquared(validX,validY,reg.slope,reg.intercept);
174
+ var xMin=Math.min.apply(null,validX),xMax=Math.max.apply(null,validX);
175
+ var px0=u.valToPos(xMin,'x',true),px1=u.valToPos(xMax,'x',true);
176
+ var py0=u.valToPos(reg.slope*xMin+reg.intercept,scaleKey,true);
177
+ var py1=u.valToPos(reg.slope*xMax+reg.intercept,scaleKey,true);
178
+ ctx.strokeStyle=scfg.trendlineColor||scfg.color;ctx.lineWidth=scfg.trendlineWidth;
179
+ ctx.setLineDash(scfg.trendlineDash?[6,4]:[]);ctx.globalAlpha=0.75;
180
+ ctx.beginPath();ctx.moveTo(px0,py0);ctx.lineTo(px1,py1);ctx.stroke();
181
+ ctx.setLineDash([]);ctx.globalAlpha=1;
182
+ ctx.font='600 10px -apple-system,sans-serif';ctx.fillStyle=scfg.trendlineColor||scfg.color;ctx.globalAlpha=0.8;
183
+ ctx.fillText('R\u00b2='+r2.toFixed(3),px1-58,py1-6);ctx.globalAlpha=1;
184
+ }
185
+ });
186
+ ctx.restore();
187
+ }]
188
+ }
189
+ };
190
+ }
191
+
192
+ window._makeScatterPlugin = makeScatterPlugin;
193
+
194
+ })();
195
+ """
@@ -0,0 +1,242 @@
1
+ """Serializer — Python data to JS strings for uPlot."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ from youplot.series.line import LineSeries
6
+
7
+
8
+ def series_to_js_array(values: list, var_name: str) -> str:
9
+ nums = ["NaN" if v is None else repr(round(v, 6)) for v in values]
10
+ return f"const {var_name} = new Float64Array([{','.join(nums)}]);"
11
+
12
+
13
+ def timestamps_to_js_array(ts_ms, var_name: str) -> str:
14
+ secs = [str(t / 1000) for t in ts_ms]
15
+ return f"const {var_name} = new Float64Array([{','.join(secs)}]);"
16
+
17
+
18
+ def numeric_to_js_array(vals, var_name: str) -> str:
19
+ nums = [repr(float(v)) for v in vals]
20
+ return f"const {var_name} = new Float64Array([{','.join(nums)}]);"
21
+
22
+
23
+ def series_config_to_js(series: list) -> str:
24
+ parts = ["{}"]
25
+ for s in series:
26
+ dash = s.resolved_dash()
27
+ dash_js = f"[{','.join(map(str, dash))}]" if dash else "[]"
28
+ cfg = (
29
+ "{"
30
+ f'label:{json.dumps(s.label)},'
31
+ f'scale:{json.dumps(s._scale_name)},'
32
+ f'stroke:{json.dumps(s.color)},'
33
+ f'width:{s.width},'
34
+ f'dash:{dash_js},'
35
+ f'points:{{show:{"true" if s.points else "false"},size:{s.points_size*2},fill:{json.dumps(s.resolved_points_color())}}},'
36
+ f'_fill:{json.dumps(s.fill)},'
37
+ f'_fillOpacity:{s.fill_opacity},'
38
+ f'_fillColor:{json.dumps(s.resolved_fill_color())},'
39
+ f'_hoverUnit:{json.dumps(s.hover_unit)},'
40
+ f'_hoverFormat:{json.dumps(s.hover_format)},'
41
+ "}"
42
+ )
43
+ parts.append(cfg)
44
+ return "[" + ",".join(parts) + "]"
45
+
46
+
47
+ def scales_config_to_js(scale_names, right_scales, x_range, range_map, is_timeseries=True) -> str:
48
+ """
49
+ Put min/max in scale config for correct initial render.
50
+ uPlot scale config min/max sets the initial range but does NOT lock it —
51
+ setScale() and drag-zoom can always override it afterward.
52
+ """
53
+ parts = []
54
+ if x_range:
55
+ time_flag = '"time":true,' if is_timeseries else ''
56
+ parts.append(f'"x":{{{time_flag}"min":{x_range[0]},"max":{x_range[1]}}}')
57
+ else:
58
+ time_flag = '"time":true' if is_timeseries else '"time":false'
59
+ parts.append(f'"x":{{{time_flag}}}')
60
+ for name in scale_names:
61
+ rng = range_map.get(name)
62
+ if rng:
63
+ parts.append(f'{json.dumps(name)}:{{"auto":false,"min":{rng[0]},"max":{rng[1]}}}')
64
+ else:
65
+ parts.append(f'{json.dumps(name)}:{{"auto":true}}')
66
+ return "{" + ",".join(parts) + "}"
67
+
68
+
69
+ def initial_ranges_to_js(x_range, range_map, is_timeseries) -> str:
70
+ """
71
+ Returns a JS object of scale→range pairs to apply in the ready hook.
72
+ e.g. {"x":[0,100],"y":[0,100]}
73
+ """
74
+ ranges = {}
75
+ if x_range:
76
+ ranges["x"] = [x_range[0], x_range[1]]
77
+ for name, rng in range_map.items():
78
+ if rng:
79
+ ranges[name] = [rng[0], rng[1]]
80
+ return json.dumps(ranges)
81
+
82
+
83
+ def axes_config_to_js(
84
+ scale_names, right_scales, theme,
85
+ x_label, y_label, y_right_label,
86
+ x_format, y_format,
87
+ is_timeseries=True,
88
+ ) -> str:
89
+ t = theme
90
+ axes = []
91
+ x_cfg = (
92
+ "{"
93
+ f'stroke:{json.dumps(t.text_tick)},'
94
+ f'grid:{{stroke:{json.dumps(t.grid_color)},width:1}},'
95
+ f'ticks:{{stroke:{json.dumps(t.tick_stroke)},width:1,size:5}},'
96
+ f'font:"11px -apple-system,BlinkMacSystemFont,Inter,sans-serif",'
97
+ + (f'label:{json.dumps(x_label)},' if x_label else '')
98
+ + "}"
99
+ )
100
+ axes.append(x_cfg)
101
+ for name in scale_names:
102
+ is_right = name in right_scales
103
+ side = 1 if is_right else 3
104
+ label = y_right_label if is_right else y_label
105
+ cfg = (
106
+ "{"
107
+ f'scale:{json.dumps(name)},'
108
+ f'side:{side},'
109
+ f'stroke:{json.dumps(t.text_tick)},'
110
+ f'grid:{{stroke:{json.dumps(t.grid_color)},width:1}},'
111
+ f'ticks:{{stroke:{json.dumps(t.tick_stroke)},width:1,size:5}},'
112
+ f'font:"11px -apple-system,BlinkMacSystemFont,Inter,sans-serif",'
113
+ + (f'label:{json.dumps(label)},' if label else '')
114
+ + "}"
115
+ )
116
+ axes.append(cfg)
117
+ return "[" + ",".join(axes) + "]"
118
+
119
+
120
+ def annotations_to_js(vlines, hlines, regions) -> str:
121
+ # Map user-facing axis names to the internal uPlot scale keys used in UP_SCALES
122
+ _AXIS_TO_SCALE = {"left": "y", "right": "y2"}
123
+
124
+ def dc(obj):
125
+ d = {k: v for k, v in vars(obj).items()}
126
+ # ensure _tagId and _removable are always present
127
+ d.setdefault('_tagId', '')
128
+ d.setdefault('_removable', True)
129
+ return d
130
+
131
+ def dc_hline(h):
132
+ d = dc(h)
133
+ # Translate "left"/"right" to the actual uPlot scale key ("y"/"y2")
134
+ if d.get('scale') in _AXIS_TO_SCALE:
135
+ d['scale'] = _AXIS_TO_SCALE[d['scale']]
136
+ return d
137
+
138
+ return (
139
+ f"const UP_VLINES = {json.dumps([dc(v) for v in vlines])};\n"
140
+ f"const UP_HLINES = {json.dumps([dc_hline(h) for h in hlines])};\n"
141
+ f"let UP_REGIONS = {json.dumps([dc(r) for r in regions])};\n"
142
+ )
143
+
144
+
145
+ def bands_to_js(bands) -> str:
146
+ """Serialize threshold bands to JS array."""
147
+ _AXIS_TO_SCALE = {"left": "y", "right": "y2"}
148
+
149
+ def dc(b):
150
+ d = {k: v for k, v in vars(b).items()}
151
+ if d.get('scale') in _AXIS_TO_SCALE:
152
+ d['scale'] = _AXIS_TO_SCALE[d['scale']]
153
+ return d
154
+ return json.dumps([dc(b) for b in bands])
155
+ from youplot.utils.data import to_float_list
156
+
157
+ all_decls = []
158
+ cfg_parts = []
159
+
160
+ for i, s in enumerate(scatter_series):
161
+ x_var = f"SC_X_{i}"
162
+ y_var = f"SC_Y_{i}"
163
+ sz_var = f"SC_SZ_{i}"
164
+ cv_var = f"SC_COL_{i}"
165
+ lv_var = f"SC_LBL_{i}"
166
+
167
+ x_vals = to_float_list(s.x)
168
+ if is_timeseries:
169
+ x_nums = [str(v/1000) if v is not None else "NaN" for v in x_vals]
170
+ else:
171
+ x_nums = [repr(float(v)) if v is not None else "NaN" for v in x_vals]
172
+ all_decls.append(f"const {x_var} = [{','.join(x_nums)}];")
173
+
174
+ y_vals = to_float_list(s.y)
175
+ y_nums = [repr(float(v)) if v is not None else "NaN" for v in y_vals]
176
+ all_decls.append(f"const {y_var} = [{','.join(y_nums)}];")
177
+
178
+ def opt_arr(data, var):
179
+ if data is None:
180
+ return f"const {var} = null;"
181
+ vals = to_float_list(data)
182
+ nums = [repr(float(v)) if v is not None else "NaN" for v in vals]
183
+ return f"const {var} = [{','.join(nums)}];"
184
+
185
+ all_decls.append(opt_arr(s.size_by, sz_var))
186
+ all_decls.append(opt_arr(s.color_by, cv_var))
187
+
188
+ if s.labels is not None:
189
+ all_decls.append(f"const {lv_var} = {json.dumps([str(l) for l in s.labels])};")
190
+ else:
191
+ all_decls.append(f"const {lv_var} = null;")
192
+
193
+ cfg = (
194
+ "{"
195
+ f"_scatterIdx:{i},"
196
+ f"label:{json.dumps(s.label)},"
197
+ f"color:{json.dumps(s.color)},"
198
+ f"size:{s.size},"
199
+ f"opacity:{s.opacity},"
200
+ f"shape:{json.dumps(s.shape)},"
201
+ f"stroke:{json.dumps(s.resolved_stroke())},"
202
+ f"strokeWidth:{s.stroke_width},"
203
+ f"sizeRange:[{s.size_range[0]},{s.size_range[1]}],"
204
+ f"colorScale:[{','.join(json.dumps(c) for c in s.color_scale)}],"
205
+ f"trendline:{'true' if s.trendline else 'false'},"
206
+ f"trendlineColor:{json.dumps(s.resolved_trendline_color())},"
207
+ f"trendlineWidth:{s.trendline_width},"
208
+ f"trendlineDash:{'true' if s.trendline_dash else 'false'},"
209
+ f"hoverFormat:{json.dumps(s.hover_format)},"
210
+ f"hoverUnit:{json.dumps(s.hover_unit)},"
211
+ f"hoverXLabel:{json.dumps(s.hover_x_label)},"
212
+ f"hoverYLabel:{json.dumps(s.hover_y_label)},"
213
+ f"labelFontSize:{s.label_font_size},"
214
+ f"labelColor:{json.dumps(s.resolved_label_color())},"
215
+ f"scale:{json.dumps(s._scale_name)},"
216
+ f"visible:true,"
217
+ f"_xData:{x_var},"
218
+ f"_yData:{y_var},"
219
+ f"_sizeData:{sz_var},"
220
+ f"_colorData:{cv_var},"
221
+ f"_labelData:{lv_var},"
222
+ "}"
223
+ )
224
+ cfg_parts.append(cfg)
225
+
226
+ return "\n".join(all_decls), "[" + ",".join(cfg_parts) + "]"
227
+
228
+ def pins_to_js(pins) -> str:
229
+ """Serialize code-defined annotation pins to a JS array."""
230
+ import json as _json
231
+ out = []
232
+ for p in pins:
233
+ out.append({
234
+ 'id': f'code_pin_{id(p)}',
235
+ 'x': p.x,
236
+ 'label': p.label,
237
+ 'y_frac': getattr(p, 'y_frac', 0.2),
238
+ 'y': getattr(p, 'y', None),
239
+ 'scale': getattr(p, 'scale', 'left'),
240
+ 'color': p.color or None, # None → auto-assigned in JS
241
+ })
242
+ return _json.dumps(out)
@@ -0,0 +1,2 @@
1
+ from youplot.series.line import LineSeries
2
+ from youplot.series.scatter import ScatterSeries
youplot/series/line.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ LineSeries — represents one line on the chart.
3
+ All fields are optional except x and y.
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class LineSeries:
13
+ # ── Required ──────────────────────────────────────────────────────────────
14
+ x: Any # array-like: timestamps (ms), numeric, or datetime
15
+ y: Any # array-like: numeric values
16
+
17
+ # ── Identity ──────────────────────────────────────────────────────────────
18
+ label: str = "" # legend + tooltip label
19
+
20
+ # ── Appearance ────────────────────────────────────────────────────────────
21
+ color: str = "" # resolved hex — set by Figure if empty
22
+ width: float = 2.0 # stroke width in px
23
+ opacity: float = 1.0 # line opacity 0–1
24
+
25
+ # Dash pattern
26
+ dash: bool | list[int] = False # False=solid, True=[6,3], or custom [on,off]
27
+
28
+ # Fill under line
29
+ fill: bool = False # gradient fill under line
30
+ fill_opacity: float = 0.15 # max opacity of fill gradient
31
+ fill_color: str = "" # defaults to line color
32
+
33
+ # ── Axis binding ──────────────────────────────────────────────────────────
34
+ axis: str = "left" # "left" or "right"
35
+ scale: str = "" # explicit scale name — auto-set if empty
36
+
37
+ # ── Points ────────────────────────────────────────────────────────────────
38
+ points: bool = False # show dots at each data point
39
+ points_size: float = 4.0 # dot radius in px
40
+ points_color: str = "" # defaults to line color
41
+ points_filled: bool = True # filled vs outline
42
+
43
+ # ── Smoothing / shape ─────────────────────────────────────────────────────
44
+ smooth: bool = False # spline interpolation (uPlot path: spline)
45
+ step: bool = False # step line
46
+
47
+ # ── Nulls & gaps ─────────────────────────────────────────────────────────
48
+ gap_threshold: float | None = None # seconds — break line if gap exceeds this
49
+ null_handling: str = "gap" # "gap" | "zero" | "ignore"
50
+
51
+ # ── Tooltip ───────────────────────────────────────────────────────────────
52
+ hover_format: str = "" # e.g. ".2f" — python format spec
53
+ hover_unit: str = "" # suffix shown in tooltip e.g. "V", "%", "A"
54
+
55
+ # ── Internal (set by Figure, not user) ────────────────────────────────────
56
+ _scale_name: str = field(default="", init=False, repr=False)
57
+
58
+ def resolved_dash(self) -> list[int] | None:
59
+ """Return JS dash array or None for solid."""
60
+ if self.dash is False:
61
+ return None
62
+ if self.dash is True:
63
+ return [6, 3]
64
+ return self.dash
65
+
66
+ def resolved_fill_color(self) -> str:
67
+ return self.fill_color if self.fill_color else self.color
68
+
69
+ def resolved_points_color(self) -> str:
70
+ return self.points_color if self.points_color else self.color
@@ -0,0 +1,76 @@
1
+ """
2
+ ScatterSeries — represents one scatter series on the chart.
3
+ Scatter in uPlot is rendered via a custom canvas plugin (no native scatter).
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class ScatterSeries:
13
+ # ── Required ──────────────────────────────────────────────────────────────
14
+ x: Any # array-like: x values
15
+ y: Any # array-like: y values
16
+
17
+ # ── Identity ──────────────────────────────────────────────────────────────
18
+ label: str = ""
19
+
20
+ # ── Point appearance ──────────────────────────────────────────────────────
21
+ color: str = "" # resolved hex — set by Figure if empty
22
+ size: float = 6.0 # dot radius in px
23
+ opacity: float = 0.85 # fill opacity
24
+
25
+ # Shape: "circle" | "square" | "triangle" | "diamond" | "cross" | "star"
26
+ shape: str = "circle"
27
+
28
+ # Outline
29
+ stroke: str = "" # border color — defaults to color (darker)
30
+ stroke_width: float = 1.0 # border width (0 = no border)
31
+
32
+ # ── Size encoding — map a third array to point size ───────────────────────
33
+ size_by: Any = None # array-like — values mapped to point sizes
34
+ size_range: list[float] = field(default_factory=lambda: [3.0, 18.0]) # [min_px, max_px]
35
+
36
+ # ── Color encoding — map a third array to point color (gradient) ──────────
37
+ color_by: Any = None # array-like — values mapped to color scale
38
+ color_scale: list[str] = field(default_factory=lambda: ["#6366f1", "#f43f5e"]) # [low, high]
39
+
40
+ # ── Axis binding ──────────────────────────────────────────────────────────
41
+ axis: str = "left"
42
+ scale: str = ""
43
+
44
+ # ── Regression line ───────────────────────────────────────────────────────
45
+ trendline: bool = False # draw linear regression line
46
+ trendline_color: str = "" # defaults to series color
47
+ trendline_width: float = 1.5
48
+ trendline_dash: bool = True
49
+
50
+ # ── Hover ─────────────────────────────────────────────────────────────────
51
+ hover_format: str = ""
52
+ hover_unit: str = ""
53
+ hover_x_label: str = "x" # label for x value in tooltip
54
+ hover_y_label: str = "y" # label for y value in tooltip
55
+
56
+ # ── Labels on points ──────────────────────────────────────────────────────
57
+ labels: Any = None # array-like of strings — shown next to each point
58
+ label_font_size: int = 9
59
+ label_color: str = "" # defaults to color
60
+
61
+ # ── Jitter — avoid overplotting ───────────────────────────────────────────
62
+ jitter_x: float = 0.0 # random x offset in data units
63
+ jitter_y: float = 0.0 # random y offset in data units
64
+
65
+ # ── Internal ──────────────────────────────────────────────────────────────
66
+ _scale_name: str = field(default="", init=False, repr=False)
67
+
68
+ def resolved_stroke(self) -> str:
69
+ """Border color — slightly darker version of fill color if not set."""
70
+ return self.stroke if self.stroke else self.color
71
+
72
+ def resolved_trendline_color(self) -> str:
73
+ return self.trendline_color if self.trendline_color else self.color
74
+
75
+ def resolved_label_color(self) -> str:
76
+ return self.label_color if self.label_color else self.color
File without changes