assets/app.js
Snapshot 2026.09.17-a2894de (current) · 65 lines · 4,037 bytes · tree · plain text
SHA-256
b56d9cacf29ef638bc9344dcabaf2165b172429a8d3f960774fa420e26311656| 1 | /* Research Labs charts: dependency-free SVG line charts. |
| 2 | Rules applied: single axis, 2px lines, recessive grid, legend for >=2 series, |
| 3 | crosshair + tooltip, text in ink tokens, fixed series colours. */ |
| 4 | const SERIES=['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767']; |
| 5 | const tip=(()=>{const d=document.createElement('div');d.className='tip';document.body.appendChild(d);return d;})(); |
| 6 | |
| 7 | function drawChart(el){ |
| 8 | const cfg=JSON.parse(el.dataset.chart); // {series:[{label,slot,data:[[ts,v],...]}], unit, normalize} |
| 9 | const W=el.clientWidth||800, H=el.clientHeight||220, P={l:46,r:12,t:10,b:22}; |
| 10 | const all=cfg.series.flatMap(s=>s.data); |
| 11 | if(!all.length){el.innerHTML='<div class="kv">No data in range.</div>';return;} |
| 12 | const ts=all.map(d=>+new Date(d[0].replace(' ','T')+':00Z')); |
| 13 | const t0=Math.min(...ts), t1=Math.max(...ts); |
| 14 | const norm=cfg.normalize&&cfg.series.length>1; |
| 15 | const yFor=s=>{const vs=s.data.map(d=>d[1]);const mn=Math.min(...vs),mx=Math.max(...vs);return v=> norm? (mx===mn?50:(v-mn)/(mx-mn)*100) : v;}; |
| 16 | let vmin=Infinity,vmax=-Infinity; |
| 17 | cfg.series.forEach(s=>{const f=yFor(s);s.data.forEach(d=>{const v=f(d[1]);if(v<vmin)vmin=v;if(v>vmax)vmax=v;});}); |
| 18 | if(vmin===vmax){vmin-=1;vmax+=1;} |
| 19 | const X=t=>P.l+( (t-t0)/(t1-t0||1) )*(W-P.l-P.r); |
| 20 | const Y=v=>H-P.b-((v-vmin)/(vmax-vmin))*(H-P.t-P.b); |
| 21 | let g=''; |
| 22 | const steps=4; |
| 23 | for(let i=0;i<=steps;i++){const v=vmin+(vmax-vmin)*i/steps,y=Y(v); |
| 24 | g+=`<line class="gridline" x1="${P.l}" x2="${W-P.r}" y1="${y}" y2="${y}"/>`; |
| 25 | g+=`<text x="4" y="${y+3}">${norm?Math.round(v)+'%':fmt(v)}</text>`;} |
| 26 | const days=(t1-t0)/86400000; |
| 27 | const nlab=Math.min(6,Math.max(2,Math.round(days/10)+2)); |
| 28 | for(let i=0;i<nlab;i++){const t=t0+(t1-t0)*i/(nlab-1),x=X(t); |
| 29 | g+=`<text x="${x-24}" y="${H-6}">${new Date(t).toISOString().slice(5,10)}</text>`;} |
| 30 | cfg.series.forEach((s,si)=>{ |
| 31 | const f=yFor(s); const col=SERIES[(s.slot??si)%SERIES.length]; |
| 32 | const pts=s.data.map(d=>`${X(+new Date(d[0].replace(' ','T')+':00Z')).toFixed(1)},${Y(f(d[1])).toFixed(1)}`).join(' '); |
| 33 | g+=`<polyline fill="none" stroke="${col}" stroke-width="2" points="${pts}"/>`; |
| 34 | }); |
| 35 | g+=`<line class="axis" x1="${P.l}" x2="${W-P.r}" y1="${H-P.b}" y2="${H-P.b}"/>`; |
| 36 | el.innerHTML=`<svg width="${W}" height="${H}" role="img">${g}</svg>`; |
| 37 | if(cfg.series.length>1){ |
| 38 | el.insertAdjacentHTML('afterend','<div class="legend">'+cfg.series.map((s,si)=>`<span><i class="sw" style="background:${SERIES[(s.slot??si)%SERIES.length]}"></i>${s.label}${norm?' (indexed)':''}</span>`).join('')+'</div>'); |
| 39 | } |
| 40 | el.onmousemove=e=>{ |
| 41 | const r=el.getBoundingClientRect(), fx=e.clientX-r.left; |
| 42 | const t=t0+((fx-P.l)/(W-P.l-P.r))*(t1-t0); |
| 43 | let rows=''; |
| 44 | cfg.series.forEach((s,si)=>{ |
| 45 | let best=null,bd=1e18; |
| 46 | s.data.forEach(d=>{const dt=Math.abs(+new Date(d[0].replace(' ','T')+':00Z')-t);if(dt<bd){bd=dt;best=d;}}); |
| 47 | if(best)rows+=`<div><i class="sw" style="background:${SERIES[(s.slot??si)%SERIES.length]}"></i>${s.label}: <b>${fmt(best[1])}</b>${cfg.unit?' '+cfg.unit:''} <span style="color:#8b8b83">${best[0]}</span></div>`; |
| 48 | }); |
| 49 | tip.innerHTML=rows; tip.style.display='block'; |
| 50 | tip.style.left=Math.min(e.clientX+14,innerWidth-260)+'px'; tip.style.top=(e.clientY+12)+'px'; |
| 51 | }; |
| 52 | el.onmouseleave=()=>tip.style.display='none'; |
| 53 | } |
| 54 | function fmt(v){return Math.abs(v)>=100?v.toFixed(0):Math.abs(v)>=10?v.toFixed(1):v.toFixed(2);} |
| 55 | function spark(el){ |
| 56 | const d=JSON.parse(el.dataset.spark); if(!d.length){el.textContent='';return;} |
| 57 | const W=el.clientWidth||140,H=26,vs=d.map(x=>x[1]),mn=Math.min(...vs),mx=Math.max(...vs); |
| 58 | const pts=d.map((x,i)=>`${(i/(d.length-1))*(W-2)+1},${H-2-((x[1]-mn)/(mx-mn||1))*(H-4)}`).join(' '); |
| 59 | el.innerHTML=`<svg width="${W}" height="${H}"><polyline fill="none" stroke="#8b8b83" stroke-width="1.5" points="${pts}"/></svg>`; |
| 60 | } |
| 61 | addEventListener('DOMContentLoaded',()=>{ |
| 62 | document.querySelectorAll('[data-chart]').forEach(drawChart); |
| 63 | document.querySelectorAll('[data-spark]').forEach(spark); |
| 64 | }); |
| 65 | |
Lines can be cited as assets/app.js L120-L140, platform code 2026.09.17-a2894de; add #L120-L140 to this page's address to highlight them. Source shown for scrutiny of the research platform. All rights reserved. Not offered as installable software.