Spaces:
Running
Running
| <html lang="de"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <meta name="description" content="Sensor Data Analyzer: CSV-Sensordaten lokal im Browser analysieren, visualisieren und Anomalien erkennen."> | |
| <title>Sensor Data Analyzer</title> | |
| <style> | |
| :root{ | |
| --bg:#06131f;--panel:#0b2233;--panel2:#0f2c40;--line:#24485e; | |
| --text:#eef7fb;--muted:#9db5c2;--cyan:#35d7e7;--blue:#4b8cff; | |
| --green:#65d69b;--yellow:#ffd166;--red:#ff7e8a;--shadow:0 18px 60px rgba(0,0,0,.28); | |
| } | |
| *{box-sizing:border-box} | |
| body{ | |
| margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; | |
| background: | |
| radial-gradient(circle at 8% 0%,rgba(53,215,231,.13),transparent 29rem), | |
| radial-gradient(circle at 92% 8%,rgba(75,140,255,.13),transparent 30rem), | |
| var(--bg); | |
| color:var(--text);line-height:1.5; | |
| } | |
| .wrap{max-width:1200px;margin:auto;padding:24px 18px 60px} | |
| .hero,.panel{ | |
| border:1px solid var(--line);background:rgba(11,34,51,.95);border-radius:22px; | |
| box-shadow:var(--shadow) | |
| } | |
| .hero{padding:28px;margin-bottom:18px;background:linear-gradient(135deg,rgba(15,44,64,.96),rgba(6,19,31,.97))} | |
| .badge{display:inline-flex;gap:8px;align-items:center;padding:7px 11px;border-radius:999px; | |
| background:rgba(53,215,231,.09);border:1px solid rgba(53,215,231,.25);color:#c3fbff;font-size:13px;font-weight:800} | |
| h1{font-size:clamp(33px,5vw,58px);margin:15px 0 9px;letter-spacing:-.04em;line-height:1.02} | |
| .hero p{color:var(--muted);max-width:820px;font-size:17px;margin:0} | |
| .actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px} | |
| button,.file-label{ | |
| border:0;border-radius:12px;padding:11px 15px;font-weight:800;cursor:pointer;font-size:14px; | |
| transition:.15s transform,.15s opacity;display:inline-flex;align-items:center;gap:7px | |
| } | |
| button:hover,.file-label:hover{transform:translateY(-1px)} | |
| .primary{background:linear-gradient(135deg,var(--cyan),var(--blue));color:#04111c} | |
| .secondary{background:#16384e;color:var(--text);border:1px solid var(--line)} | |
| .ghost{background:transparent;color:var(--muted);border:1px solid var(--line)} | |
| input[type=file]{display:none} | |
| .privacy{display:flex;gap:10px;align-items:flex-start;margin-top:18px;border:1px solid rgba(101,214,155,.25); | |
| background:rgba(101,214,155,.06);padding:13px 15px;border-radius:15px;font-size:13px} | |
| .privacy strong{color:#baffd4} | |
| .grid{display:grid;grid-template-columns:330px 1fr;gap:18px} | |
| .panel{padding:19px} | |
| .panel h2{margin:0 0 5px;font-size:19px} | |
| .sub{font-size:13px;color:var(--muted);margin-bottom:15px} | |
| .control{margin-bottom:14px} | |
| .control label{display:block;font-size:12px;font-weight:800;color:#cfe0e8;margin-bottom:6px} | |
| select,input[type=range],input[type=number]{ | |
| width:100%;border:1px solid var(--line);border-radius:10px;background:#071a28;color:var(--text);padding:10px | |
| } | |
| input[type=range]{padding:0} | |
| .range-row{display:grid;grid-template-columns:1fr 52px;gap:9px;align-items:center} | |
| .range-value{background:#071a28;border:1px solid var(--line);border-radius:9px;padding:7px;text-align:center;font-size:12px} | |
| .fileinfo{padding:11px;border:1px solid var(--line);background:#071a28;border-radius:12px;color:var(--muted);font-size:12px;margin:12px 0} | |
| .metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:16px} | |
| .metric{border:1px solid var(--line);background:#081d2b;border-radius:14px;padding:13px;min-width:0} | |
| .metric span{display:block;color:var(--muted);font-size:11px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .metric strong{font-size:20px;letter-spacing:-.03em} | |
| .chart-wrap{position:relative;height:420px;border:1px solid var(--line);border-radius:16px;background:#061925;overflow:hidden} | |
| canvas{width:100%;height:100%;display:block} | |
| .chart-note{font-size:12px;color:var(--muted);margin-top:9px} | |
| .legend{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:8px} | |
| .dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px} | |
| .table-wrap{overflow:auto;max-height:330px;border:1px solid var(--line);border-radius:13px;margin-top:12px} | |
| table{width:100%;border-collapse:collapse;font-size:12px} | |
| th,td{padding:9px 10px;border-bottom:1px solid rgba(36,72,94,.7);text-align:left;white-space:nowrap} | |
| th{position:sticky;top:0;background:#102c40;z-index:1} | |
| .empty{padding:26px;color:var(--muted);text-align:center} | |
| .notice{margin-top:18px;padding:15px 16px;border-radius:15px;border:1px solid rgba(255,209,102,.27); | |
| background:rgba(255,209,102,.06);font-size:13px;color:#dbe7ec} | |
| footer{text-align:center;color:#6f8998;font-size:12px;margin-top:24px} | |
| .status-good{color:var(--green)} .status-warn{color:var(--yellow)} | |
| @media(max-width:900px){ | |
| .grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(3,1fr)} | |
| } | |
| @media(max-width:560px){ | |
| .wrap{padding:12px 10px 40px}.hero,.panel{padding:16px;border-radius:16px} | |
| .metrics{grid-template-columns:repeat(2,1fr)}.chart-wrap{height:330px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <section class="hero"> | |
| <span class="badge">📡 Sensorik · Local-first Sensor Analysis</span> | |
| <h1>Sensor Data Analyzer</h1> | |
| <p>CSV-Messdaten öffnen, Sensorverläufe visualisieren, statistisch zusammenfassen und auffällige Messwerte mit robuster MAD-Analyse erkennen.</p> | |
| <div class="actions"> | |
| <label class="file-label primary" for="fileInput">📂 CSV öffnen</label> | |
| <input id="fileInput" type="file" accept=".csv,text/csv"> | |
| <button class="secondary" onclick="loadDemo()">Beispieldaten laden</button> | |
| <button class="ghost" onclick="downloadAnomalies()">Anomalien exportieren</button> | |
| </div> | |
| <div class="privacy"> | |
| <div>🔒</div> | |
| <div><strong>Lokale Verarbeitung:</strong> CSV-Dateien und Messwerte bleiben in deinem Browser. Dieser Static Space überträgt keine hochgeladenen Sensordaten an einen Server.</div> | |
| </div> | |
| </section> | |
| <div class="grid"> | |
| <aside class="panel"> | |
| <h2>Analyse konfigurieren</h2> | |
| <div class="sub">Messspalte und Erkennung anpassen</div> | |
| <div class="fileinfo" id="fileInfo">Noch keine Daten geladen.</div> | |
| <div class="control"> | |
| <label for="xColumn">Zeit / X-Achse</label> | |
| <select id="xColumn"></select> | |
| </div> | |
| <div class="control"> | |
| <label for="yColumn">Messwert</label> | |
| <select id="yColumn"></select> | |
| </div> | |
| <div class="control"> | |
| <label for="threshold">Anomalie-Empfindlichkeit (MAD)</label> | |
| <div class="range-row"> | |
| <input id="threshold" type="range" min="1.5" max="6" step=".1" value="3.5"> | |
| <div class="range-value" id="thresholdValue">3.5</div> | |
| </div> | |
| </div> | |
| <div class="control"> | |
| <label for="smooth">Gleitender Mittelwert</label> | |
| <div class="range-row"> | |
| <input id="smooth" type="range" min="1" max="30" step="1" value="5"> | |
| <div class="range-value" id="smoothValue">5</div> | |
| </div> | |
| </div> | |
| <div class="control"> | |
| <label for="limit">Max. Punkte im Diagramm</label> | |
| <select id="limit"> | |
| <option value="500">500</option> | |
| <option value="1000" selected>1.000</option> | |
| <option value="2500">2.500</option> | |
| <option value="5000">5.000</option> | |
| </select> | |
| </div> | |
| <button class="secondary" style="width:100%;justify-content:center" onclick="analyze()">Analyse aktualisieren</button> | |
| </aside> | |
| <main> | |
| <section class="panel"> | |
| <h2>Messreihe</h2> | |
| <div class="sub">Statistik und Verlauf der ausgewählten Sensorgröße</div> | |
| <div class="metrics" id="metrics"> | |
| <div class="metric"><span>Messwerte</span><strong>–</strong></div> | |
| <div class="metric"><span>Mittelwert</span><strong>–</strong></div> | |
| <div class="metric"><span>Median</span><strong>–</strong></div> | |
| <div class="metric"><span>Minimum</span><strong>–</strong></div> | |
| <div class="metric"><span>Maximum</span><strong>–</strong></div> | |
| <div class="metric"><span>Anomalien</span><strong>–</strong></div> | |
| </div> | |
| <div class="chart-wrap"><canvas id="chart"></canvas></div> | |
| <div class="legend"> | |
| <span><i class="dot" style="background:#35d7e7"></i>Messwert</span> | |
| <span><i class="dot" style="background:#ffd166"></i>Gleitender Mittelwert</span> | |
| <span><i class="dot" style="background:#ff7e8a"></i>Erkannte Anomalie</span> | |
| </div> | |
| <div class="chart-note" id="chartNote">Lade eine CSV-Datei oder Beispieldaten.</div> | |
| </section> | |
| <section class="panel" style="margin-top:18px"> | |
| <h2>Auffällige Messwerte</h2> | |
| <div class="sub">Robuste MAD-Erkennung – fachliche Interpretation bleibt erforderlich</div> | |
| <div class="table-wrap" id="anomalyTable"><div class="empty">Noch keine Analyse vorhanden.</div></div> | |
| </section> | |
| </main> | |
| </div> | |
| <div class="notice"> | |
| <strong>Hinweis:</strong> Eine statistische Anomalie ist nicht automatisch ein Sensorfehler, Defekt oder Sicherheitsereignis. Prozesszustand, Kalibrierung, physikalische Grenzwerte und Messbedingungen müssen berücksichtigt werden. Das Tool ist nicht für sicherheitskritische Entscheidungen zertifiziert. | |
| </div> | |
| <footer>Sensorik · Independent Hugging Face Community Project</footer> | |
| </div> | |
| <script> | |
| let rows=[], headers=[], anomalies=[], currentValues=[], currentLabels=[]; | |
| const $=id=>document.getElementById(id); | |
| $("threshold").addEventListener("input",()=>{$("thresholdValue").textContent=$("threshold").value; analyze();}); | |
| $("smooth").addEventListener("input",()=>{$("smoothValue").textContent=$("smooth").value; analyze();}); | |
| $("xColumn").addEventListener("change",analyze); | |
| $("yColumn").addEventListener("change",analyze); | |
| $("limit").addEventListener("change",analyze); | |
| $("fileInput").addEventListener("change",handleFile); | |
| function detectDelimiter(line){ | |
| const options=[",",";","\t","|"]; | |
| return options.map(d=>[d,(line.split(d).length-1)]).sort((a,b)=>b[1]-a[1])[0][0]; | |
| } | |
| function parseCSV(text){ | |
| const lines=text.replace(/\r/g,"").split("\n").filter(x=>x.trim().length); | |
| if(lines.length<2) throw new Error("CSV enthält zu wenige Zeilen."); | |
| const delim=detectDelimiter(lines[0]); | |
| const parseLine=line=>{ | |
| let out=[],cur="",quote=false; | |
| for(let i=0;i<line.length;i++){ | |
| const c=line[i]; | |
| if(c==='"'){ | |
| if(quote && line[i+1]==='"'){cur+='"';i++} | |
| else quote=!quote; | |
| } else if(c===delim && !quote){out.push(cur.trim());cur=""} | |
| else cur+=c; | |
| } | |
| out.push(cur.trim()); return out; | |
| }; | |
| const h=parseLine(lines[0]).map((x,i)=>x||("column_"+(i+1))); | |
| const data=lines.slice(1).map(line=>{ | |
| const vals=parseLine(line),o={}; | |
| h.forEach((k,i)=>o[k]=vals[i]??""); | |
| return o; | |
| }); | |
| return {headers:h,rows:data,delimiter:delim}; | |
| } | |
| function toNum(v){ | |
| if(v===null||v===undefined||v==="") return NaN; | |
| let s=String(v).trim().replace(/\s/g,""); | |
| if(/^[-+]?\d{1,3}(\.\d{3})*,\d+$/.test(s)) s=s.replace(/\./g,"").replace(",","."); | |
| else if(/^[-+]?\d+,\d+$/.test(s)) s=s.replace(",","."); | |
| const n=Number(s); | |
| return Number.isFinite(n)?n:NaN; | |
| } | |
| function numericShare(col){ | |
| if(!rows.length)return 0; | |
| const vals=rows.slice(0,Math.min(300,rows.length)); | |
| return vals.filter(r=>Number.isFinite(toNum(r[col]))).length/vals.length; | |
| } | |
| function setupSelectors(){ | |
| $("xColumn").innerHTML=`<option value="__index__">Zeilenindex</option>`+headers.map(h=>`<option value="${esc(h)}">${esc(h)}</option>`).join(""); | |
| const nums=headers.filter(h=>numericShare(h)>.6); | |
| $("yColumn").innerHTML=nums.map(h=>`<option value="${esc(h)}">${esc(h)}</option>`).join(""); | |
| if(nums.length) $("yColumn").value=nums[0]; | |
| const likelyTime=headers.find(h=>/time|date|datum|zeit|timestamp/i.test(h)); | |
| if(likelyTime) $("xColumn").value=likelyTime; | |
| } | |
| function esc(s){return String(s).replace(/[&<>"']/g,m=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[m]))} | |
| async function handleFile(e){ | |
| const f=e.target.files[0]; if(!f)return; | |
| try{ | |
| const text=await f.text(); | |
| const p=parseCSV(text); | |
| headers=p.headers; rows=p.rows; | |
| $("fileInfo").innerHTML=`<strong>${esc(f.name)}</strong><br>${rows.length.toLocaleString("de-DE")} Zeilen · ${headers.length} Spalten · Trennzeichen: <code>${p.delimiter===" "?"Tab":esc(p.delimiter)}</code>`; | |
| setupSelectors(); analyze(); | |
| }catch(err){ | |
| $("fileInfo").textContent="Fehler: "+err.message; | |
| } | |
| } | |
| function loadDemo(){ | |
| headers=["timestamp","temperature","vibration","pressure"]; | |
| rows=[]; | |
| const start=new Date("2026-09-16T08:00:00"); | |
| for(let i=0;i<360;i++){ | |
| const t=new Date(start.getTime()+i*60000); | |
| let temp=68 + Math.sin(i/25)*1.8 + Math.sin(i/7)*.25; | |
| let vib=2.1 + Math.sin(i/12)*.18 + Math.cos(i/5)*.08; | |
| let pressure=5.2 + Math.sin(i/50)*.12; | |
| if([91,92,93].includes(i)) temp+=12; | |
| if([218,219].includes(i)) vib+=1.7; | |
| if(i===305) pressure-=1.1; | |
| rows.push({ | |
| timestamp:t.toISOString().slice(0,16).replace("T"," "), | |
| temperature:temp.toFixed(2), | |
| vibration:vib.toFixed(3), | |
| pressure:pressure.toFixed(3) | |
| }); | |
| } | |
| $("fileInfo").innerHTML="<strong>Beispieldaten</strong><br>360 Zeilen · 3 Sensorsignale · künstliche Anomalien"; | |
| setupSelectors(); $("xColumn").value="timestamp"; $("yColumn").value="temperature"; analyze(); | |
| } | |
| function median(arr){ | |
| if(!arr.length)return NaN; | |
| const a=[...arr].sort((x,y)=>x-y),m=Math.floor(a.length/2); | |
| return a.length%2?a[m]:(a[m-1]+a[m])/2; | |
| } | |
| function stats(v){ | |
| if(!v.length)return {}; | |
| const mean=v.reduce((a,b)=>a+b,0)/v.length; | |
| const med=median(v); | |
| const sd=Math.sqrt(v.reduce((s,x)=>s+(x-mean)**2,0)/Math.max(1,v.length-1)); | |
| return {n:v.length,mean,median:med,min:Math.min(...v),max:Math.max(...v),sd}; | |
| } | |
| function madAnomalies(vals,threshold){ | |
| const med=median(vals); | |
| const abs=vals.map(x=>Math.abs(x-med)); | |
| const mad=median(abs); | |
| if(!Number.isFinite(mad)||mad===0) return vals.map(()=>({score:0,is:false})); | |
| return vals.map(x=>{ | |
| const score=.6745*(x-med)/mad; | |
| return {score,is:Math.abs(score)>threshold}; | |
| }); | |
| } | |
| function movingAverage(vals,w){ | |
| if(w<=1)return [...vals]; | |
| const out=[],q=[];let sum=0; | |
| for(const v of vals){ | |
| q.push(v);sum+=v; | |
| if(q.length>w)sum-=q.shift(); | |
| out.push(sum/q.length); | |
| } | |
| return out; | |
| } | |
| function fmt(n){ | |
| if(!Number.isFinite(n))return "–"; | |
| return new Intl.NumberFormat("de-DE",{maximumFractionDigits:3}).format(n); | |
| } | |
| function analyze(){ | |
| if(!rows.length || !$("yColumn").value){renderEmpty();return} | |
| const xcol=$("xColumn").value,ycol=$("yColumn").value,lim=parseInt($("limit").value); | |
| let temp=[]; | |
| rows.forEach((r,i)=>{ | |
| const y=toNum(r[ycol]); | |
| if(Number.isFinite(y)) temp.push({label:xcol==="__index__"?(i+1):r[xcol],value:y,row:i}); | |
| }); | |
| if(!temp.length){renderEmpty();return} | |
| const step=Math.max(1,Math.ceil(temp.length/lim)); | |
| const sampled=temp.filter((_,i)=>i%step===0 || i===temp.length-1); | |
| currentValues=sampled.map(x=>x.value); | |
| currentLabels=sampled.map(x=>x.label); | |
| const s=stats(currentValues), threshold=parseFloat($("threshold").value); | |
| const flags=madAnomalies(currentValues,threshold); | |
| const smooth=movingAverage(currentValues,parseInt($("smooth").value)); | |
| anomalies=sampled.map((x,i)=>({...x,score:flags[i].score,is:flags[i].is})).filter(x=>x.is); | |
| $("metrics").innerHTML=[ | |
| ["Messwerte",s.n.toLocaleString("de-DE")], | |
| ["Mittelwert",fmt(s.mean)], | |
| ["Median",fmt(s.median)], | |
| ["Minimum",fmt(s.min)], | |
| ["Maximum",fmt(s.max)], | |
| ["Anomalien",anomalies.length.toLocaleString("de-DE")] | |
| ].map(x=>`<div class="metric"><span>${x[0]}</span><strong>${x[1]}</strong></div>`).join(""); | |
| drawChart(currentValues,smooth,flags); | |
| renderAnomalies(); | |
| $("chartNote").innerHTML=`Spalte <strong>${esc(ycol)}</strong> · ${temp.length.toLocaleString("de-DE")} gültige Werte`+ | |
| (step>1?` · Diagramm auf ca. ${sampled.length.toLocaleString("de-DE")} Punkte reduziert`:"")+ | |
| ` · MAD-Schwelle ${threshold}`; | |
| } | |
| function renderEmpty(){ | |
| $("chartNote").textContent="Lade eine CSV-Datei oder Beispieldaten."; | |
| $("anomalyTable").innerHTML='<div class="empty">Noch keine Analyse vorhanden.</div>'; | |
| } | |
| function renderAnomalies(){ | |
| if(!anomalies.length){ | |
| $("anomalyTable").innerHTML='<div class="empty status-good">Keine Auffälligkeiten mit der aktuellen MAD-Schwelle erkannt.</div>'; | |
| return; | |
| } | |
| $("anomalyTable").innerHTML=`<table><thead><tr><th>#</th><th>X / Zeit</th><th>Messwert</th><th>Robuster Score</th></tr></thead><tbody>`+ | |
| anomalies.slice(0,500).map((a,i)=>`<tr><td>${i+1}</td><td>${esc(a.label)}</td><td>${fmt(a.value)}</td><td>${fmt(a.score)}</td></tr>`).join("")+ | |
| `</tbody></table>`+(anomalies.length>500?`<div class="empty">${anomalies.length-500} weitere Anomalien nicht angezeigt.</div>`:""); | |
| } | |
| function drawChart(vals,smooth,flags){ | |
| const c=$("chart"),rect=c.parentElement.getBoundingClientRect(),dpr=window.devicePixelRatio||1; | |
| c.width=Math.max(300,Math.floor(rect.width*dpr)); c.height=Math.max(260,Math.floor(rect.height*dpr)); | |
| const ctx=c.getContext("2d");ctx.scale(dpr,dpr); | |
| const W=rect.width,H=rect.height,p={l:54,r:18,t:18,b:34}; | |
| ctx.clearRect(0,0,W,H); | |
| const min=Math.min(...vals),max=Math.max(...vals),pad=(max-min||1)*.08,ymin=min-pad,ymax=max+pad; | |
| const X=i=>p.l+(W-p.l-p.r)*(i/Math.max(1,vals.length-1)); | |
| const Y=v=>p.t+(H-p.t-p.b)*(1-(v-ymin)/(ymax-ymin)); | |
| ctx.strokeStyle="rgba(157,181,194,.16)";ctx.lineWidth=1;ctx.font="11px system-ui";ctx.fillStyle="#8ca6b5"; | |
| for(let j=0;j<=4;j++){ | |
| const y=p.t+(H-p.t-p.b)*j/4; | |
| ctx.beginPath();ctx.moveTo(p.l,y);ctx.lineTo(W-p.r,y);ctx.stroke(); | |
| const val=ymax-(ymax-ymin)*j/4;ctx.fillText(fmt(val),6,y+4); | |
| } | |
| function line(data,color,width){ | |
| ctx.strokeStyle=color;ctx.lineWidth=width;ctx.beginPath(); | |
| data.forEach((v,i)=>{const x=X(i),y=Y(v);i?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke(); | |
| } | |
| line(vals,"#35d7e7",1.7); line(smooth,"#ffd166",1.5); | |
| ctx.fillStyle="#ff7e8a"; | |
| flags.forEach((f,i)=>{if(f.is){ctx.beginPath();ctx.arc(X(i),Y(vals[i]),4.3,0,Math.PI*2);ctx.fill()}}); | |
| ctx.fillStyle="#8ca6b5"; | |
| const ticks=Math.min(5,vals.length); | |
| for(let j=0;j<ticks;j++){ | |
| const i=Math.round((vals.length-1)*j/Math.max(1,ticks-1)),txt=String(currentLabels[i]??i+1); | |
| const short=txt.length>18?txt.slice(0,17)+"…":txt; | |
| ctx.fillText(short,X(i)-Math.min(45,short.length*3),H-10); | |
| } | |
| } | |
| function downloadAnomalies(){ | |
| if(!anomalies.length){alert("Keine Anomalien zum Exportieren.");return} | |
| const x=$("xColumn").value,y=$("yColumn").value; | |
| let csv=`${x==="__index__"?"index":x},${y},robust_score\n`; | |
| anomalies.forEach(a=>csv+=`"${String(a.label).replace(/"/g,'""')}",${a.value},${a.score}\n`); | |
| const blob=new Blob([csv],{type:"text/csv;charset=utf-8"}); | |
| const url=URL.createObjectURL(blob),a=document.createElement("a"); | |
| a.href=url;a.download="sensorik-anomalien.csv";a.click();URL.revokeObjectURL(url); | |
| } | |
| window.addEventListener("resize",()=>{if(currentValues.length) analyze()}); | |
| </script> | |
| </body> | |
| </html> | |