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
|
@@ -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)
|
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
|