Live page: block DAG redrawn for 20 miners at 4 blocks/s

One lane per miner ranked by share, overflow in an others lane with a count.
Lane labels in a left gutter in the miner's colour (3-character tag on phones).
No per-block labels: short id on the chain tip, the lock, and on hover or tap
with a canvas tooltip (id, blue, DAA, parents, chain or side). Same-lane
overlaps nudged in a fixed sequence. Chain as one path, side blocks linked to
their first parent only, clamped cubics so no edge becomes a tall loop. Block
size and time scale step down together at density. One rAF loop, DPR aware,
paused when hidden or scrolled out. Final band and lock marker. Before and
after screenshots in docs/design/live-dag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-04 10:49:57 +01:00
parent ec3e03488f
commit 2ee37fa0e9
8 changed files with 132 additions and 47 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

View file

@ -93,7 +93,7 @@ p{margin:0}
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px} .viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
.viz-stats{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:12px;color:var(--ash)} .viz-stats{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:12px;color:var(--ash)}
.viz-stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} .viz-stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
#dag{display:block;width:100%;height:clamp(240px,38vw,380px);border-radius:12px;background:var(--obsidian)} #dag{display:block;width:100%;height:clamp(240px,38vw,380px);border-radius:12px;background:var(--obsidian);touch-action:pan-y}
.legend{display:flex;flex-wrap:wrap;gap:16px 20px;margin-top:16px;font-size:13px;color:var(--ink-2)} .legend{display:flex;flex-wrap:wrap;gap:16px 20px;margin-top:16px;font-size:13px;color:var(--ink-2)}
.legend span{display:inline-flex;align-items:center;gap:8px} .legend span{display:inline-flex;align-items:center;gap:8px}
.sw{width:14px;height:14px;border-radius:4px;display:inline-block} .sw{width:14px;height:14px;border-radius:4px;display:inline-block}
@ -182,10 +182,12 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
<div class="legend"> <div class="legend">
<span><i class="sw" style="background:var(--ember)"></i>chain block</span> <span><i class="sw" style="background:var(--ember)"></i>chain block</span>
<span><i class="sw" style="border:2px solid var(--ash)"></i>side block (blue or red, off the selected chain)</span> <span><i class="sw" style="border:2px solid var(--ash)"></i>side block (blue or red, off the selected chain)</span>
<span><i class="sw" style="border:3px solid var(--ember);border-radius:50%;background:transparent"></i>locked checkpoint, final</span> <span><i class="sw" style="border:2px solid var(--molten);border-radius:50%;background:transparent"></i>locked checkpoint, final</span>
<span><i class="sw" style="border:2px solid var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)"></i>just arrived</span> <span><i class="sw" style="border:2px solid var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)"></i>just arrived</span>
<span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>parent edge</span> <span><i class="sw" style="width:22px;height:0;border-top:2px solid var(--ember)"></i>selected chain</span>
<span><span class="mono" style="color:var(--molten)">2ea5116e</span>miner short id, one lane each</span> <span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>first parent of a side block</span>
<span><span class="mono" style="color:#5CB8FF">5d3fd79c</span>one lane per miner, colour from its id</span>
<span style="color:var(--ash)">hover or tap a block for its id, scores and parents</span>
</div> </div>
</div> </div>
@ -259,66 +261,149 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');} function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// ---------- DAG scene ---------- // ---------- DAG scene ----------
var c=$('dag'),x=c.getContext('2d'),W,H,S,blocks=[],byHash={},lanesOf={},laneOrder=[],maxLanes=6,pxPerSec=40,targetPx=40; // One canvas, one requestAnimationFrame loop. Lanes: one per miner in the visible window, most blocks on top,
var live=false,serverNow=0,serverAt=0,frozenAt=null,note='',bps=0; // the least active share an "others" lane. Labels live in a left gutter, never on blocks. Edges go under blocks.
function size(){W=c.clientWidth;H=c.clientHeight;c.width=W*dpr;c.height=H*dpr;x.setTransform(dpr,0,0,dpr,0,0);S=W<600?16:22;maxLanes=H<260?4:6;} var c=$('dag'),x=c.getContext('2d'),W=0,H=0,S=22,G=78,narrow=false,level=0,maxLanes=8;
size();window.addEventListener('resize',function(){size();draw(performance.now());}); var blocks=[],byHash={},miners={},lanes=[],laneN=1,othersN=0;
function laneCount(){return Math.max(1,Math.min(maxLanes,laneOrder.length));} var LV=[{S:22,s:14,pps:40,npps:28},{S:16,s:12,pps:26,npps:20},{S:12,s:10,pps:18,npps:14}];
function laneFor(m){m=m||'?';if(!(m in lanesOf)){lanesOf[m]=laneOrder.length;laneOrder.push(m);}return lanesOf[m]%laneCount();} var pxPerSec=40,targetPx=40,bps=0,live=false,serverNow=0,serverAt=0,frozenAt=null,note='';
function laneY(l){var n=laneCount();if(n===1)return H*0.5;return H*0.16+l*(H*0.62/(n-1));} var running=false,needSize=true,inView=true,ptrX=-1,ptrY=-1,ptrTouch=false,pin=null,laneLast=[],laneRun=[];
function jitter(h){var v=0;for(var i=0;i<8;i++)v=(v*31+h.charCodeAt(i))%1000;return (v/1000-0.5)*S*0.5;} var TOP=20,BOT=12;
// 12 hues that sit beside ember on obsidian; the index comes from the short id, so a miner keeps its colour across reloads
var PAL=['#5CB8FF','#3FD39A','#F5C542','#C49BFF','#FF7AA8','#8FD15F','#FFB08A','#6F8CFF','#D9E25A','#FF8FD6','#4FD8D8','#E0A96D'];
function colour(id){var v=0;for(var i=0;i<id.length;i++)v=(v*33+id.charCodeAt(i))>>>0;return PAL[v%PAL.length];}
function jitter(h){var v=0;for(var i=0;i<8;i++)v=(v*31+h.charCodeAt(i))%1000;return v/1000-0.5;}
function vnow(){if(frozenAt!==null)return frozenAt;return serverNow+(performance.now()-serverAt);} function vnow(){if(frozenAt!==null)return frozenAt;return serverNow+(performance.now()-serverAt);}
function laneY(l){var u=H-TOP-BOT,p=u/Math.max(1,laneN);return TOP+(l+0.5)*p;}
function size(){
needSize=false;W=c.clientWidth;H=c.clientHeight;if(!W||!H)return;
c.width=Math.round(W*dpr);c.height=Math.round(H*dpr);x.setTransform(dpr,0,0,dpr,0,0);
narrow=W<560;G=narrow?36:80;applyLevel();
}
function applyLevel(){
var L=LV[level];S=narrow?L.s:L.S;targetPx=narrow?L.npps:L.pps;
maxLanes=Math.max(2,Math.min(14,Math.floor((H-TOP-BOT)/(S*1.7))));
layoutLanes();
}
function pickLevel(){
// projected blocks on screen at the base scale drives the step, with hysteresis so it never flickers
var proj=bps*(W-G)/(narrow?LV[0].npps:LV[0].pps),up=[48,110],down=[36,85],next=level;
if(level<2&&proj>up[level])next=level+1;else if(level>0&&proj<down[level-1])next=level-1;
if(next!==level){level=next;applyLevel();}
}
function layoutLanes(){
// rank miners by blocks in the window; one lane each, the overflow shares the last lane with a count
var ids=Object.keys(miners);
ids.sort(function(a,b){return miners[b].n-miners[a].n||(a<b?-1:1);});
laneN=Math.max(1,Math.min(maxLanes,ids.length));
othersN=ids.length>laneN?ids.length-(laneN-1):0;
lanes=[];
ids.forEach(function(id,i){var m=miners[id],l=othersN&&i>=laneN-1?laneN-1:i;m.lane=l;if(m.y===undefined)m.y=laneY(l);if(!othersN||l<laneN-1)lanes[l]=m;});
while(laneLast.length<laneN){laneLast.push(0);laneRun.push(0);}
}
function ingest(d){ function ingest(d){
var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now; var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now;
var seen={}; d.blocks.forEach(function(b){var e=byHash[b.hash];
d.blocks.forEach(function(b){seen[b.hash]=1;var e=byHash[b.hash];
if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;return;} if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;return;}
laneFor(b.miner); e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,px:0,py:0,on:false};
e={hash:b.hash,ts:b.ts,rx:b.rx,blue_score:b.blue_score,parents:b.parents,chain:b.chain,miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final};
byHash[b.hash]=e;blocks.push(e);}); byHash[b.hash]=e;blocks.push(e);});
// drop what fell off the left edge long ago var cut=vnow()-((W-G)/Math.max(8,pxPerSec))*1000-20000;
var cut=vnow()-(W/pxPerSec)*1000-20000;
blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;}); blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;});
blocks.sort(function(a,b){return a.ts-b.ts;}); blocks.sort(function(a,b){return a.ts-b.ts;});
for(var id in miners)miners[id].n=0;
blocks.forEach(function(b){var m=miners[b.miner];if(!m){m=miners[b.miner]={id:b.miner,n:0,col:colour(b.miner),tag:b.miner.slice(0,3),lane:0};}m.n++;});
for(id in miners)if(!miners[id].n)delete miners[id];
bps=d.state.blocks_per_second_60s||0; bps=d.state.blocks_per_second_60s||0;
targetPx=Math.max(S*0.4,Math.min(420,bps*S*1.7)); pickLevel();layoutLanes();
var chain=0;blocks.forEach(function(b){if(b.chain)chain++;}); var vis=vnow()-((W-G)/Math.max(8,targetPx))*1000,on=0,chain=0;
$('c-screen').textContent=blocks.length;$('c-chain').textContent=chain;$('c-side').textContent=blocks.length-chain; blocks.forEach(function(b){if(b.ts>vis){on++;if(b.chain)chain++;}});
$('c-screen').textContent=on;$('c-chain').textContent=chain;$('c-side').textContent=on-chain;
$('c-newest').textContent=blocks.length?compact(blocks[blocks.length-1].blue_score):'0'; $('c-newest').textContent=blocks.length?compact(blocks[blocks.length-1].blue_score):'0';
var f=d.finality;$('c-locked').textContent=f&&f.latest_locked_index?('#'+f.latest_locked_index+' at blue '+compact(f.latest_locked_blue_score)):(f&&f.supported?'none yet':'n/a'); var f=d.finality;$('c-locked').textContent=f&&f.latest_locked_index?('#'+f.latest_locked_index+' at blue '+compact(f.latest_locked_blue_score)):(f&&f.supported?'none yet':'n/a');
if(!running&&!document.hidden)kick();
} }
function rrect(px,py,w,h,r){x.beginPath();x.moveTo(px+r,py);x.lineTo(px+w-r,py);x.quadraticCurveTo(px+w,py,px+w,py+r);x.lineTo(px+w,py+h-r);x.quadraticCurveTo(px+w,py+h,px+w-r,py+h);x.lineTo(px+r,py+h);x.quadraticCurveTo(px,py+h,px,py+h-r);x.lineTo(px,py+r);x.quadraticCurveTo(px,py,px+r,py);x.closePath();} function rrect(px,py,w,h,r){x.beginPath();x.moveTo(px+r,py);x.lineTo(px+w-r,py);x.quadraticCurveTo(px+w,py,px+w,py+r);x.lineTo(px+w,py+h-r);x.quadraticCurveTo(px+w,py+h,px+w-r,py+h);x.lineTo(px+r,py+h);x.quadraticCurveTo(px,py+h,px,py+h-r);x.lineTo(px,py+r);x.quadraticCurveTo(px,py,px+r,py);x.closePath();}
function xOf(b,t){return Math.min(W-S,W-S*1.2-(t-b.ts)*pxPerSec/1000);} // a cubic with horizontal tangents; the control offset is clamped so a link across many lanes reads as a gentle S, never a loop
function link(a,b){var d=Math.max(S*0.6,Math.min(48,Math.abs(b.px-a.px)*0.5));x.moveTo(a.px,a.py);x.bezierCurveTo(a.px+d,a.py,b.px-d,b.py,b.px,b.py);}
function hit(){
var best=null,bd=S*0.9,qx=ptrX,qy=ptrY;
if(pin){var p=byHash[pin];return p&&p.on?p:null;}
if(qx<0)return null;
for(var i=0;i<blocks.length;i++){var b=blocks[i];if(!b.on)continue;var dx=Math.abs(b.px-qx),dy=Math.abs(b.py-qy);if(dx<bd&&dy<bd&&dx+dy<bd*1.5){best=b;bd=Math.max(dx,dy);}}
return best;
}
function draw(now){ function draw(now){
pxPerSec+=(targetPx-pxPerSec)*0.05; if(needSize)size();if(!W||!H)return;
var t=vnow(); pxPerSec+=(targetPx-pxPerSec)*0.06;
var t=vnow(),xr=W-S,i,b,m,n=blocks.length,id;
for(id in miners){m=miners[id];var ty=laneY(m.lane);m.y+=(ty-m.y)*0.12;if(Math.abs(ty-m.y)<0.05)m.y=ty;}
for(i=0;i<laneN;i++){laneLast[i]=-1e9;laneRun[i]=0;}
// positions: time on x, lane on y; same-lane neighbours closer than a block are nudged up and down in a fixed sequence
for(i=0;i<n;i++){b=blocks[i];m=miners[b.miner];if(!m)continue;var l=m.lane;b.px=Math.min(xr,xr-(t-b.ts)*pxPerSec/1000);
if(b.px-laneLast[l]<S*1.25)laneRun[l]++;else laneRun[l]=0;laneLast[l]=b.px;
var k=laneRun[l]%3,dy=k===0?0:(k===1?1:-1)*S*0.62;b.py=m.y+dy;b.on=b.px>G-S;}
x.clearRect(0,0,W,H); x.clearRect(0,0,W,H);
// lane rails and miner labels // final region: everything left of the newest locked checkpoint
var n=laneCount();x.font='500 10px IBM Plex Mono, monospace';x.textAlign='left'; var lk=null,fx=-1;
for(var l=0;l<n;l++){var y=laneY(l);x.strokeStyle='rgba(42,42,48,0.7)';x.lineWidth=1;x.beginPath();x.moveTo(0,y);x.lineTo(W,y);x.stroke(); for(i=0;i<n;i++){b=blocks[i];if(b.locked&&(!lk||b.ts>lk.ts))lk=b;if(b.final&&b.px>fx)fx=b.px;}
var names=laneOrder.filter(function(m,i){return i%n===l;});if(names.length){x.fillStyle='rgba(154,154,158,0.75)';x.fillText(names.slice(0,2).join(' ')+(names.length>2?' +'+(names.length-2):''),8,y-S*0.75);}} if(lk)fx=lk.px;
var pos={};blocks.forEach(function(b){pos[b.hash]={x:xOf(b,t),y:laneY(laneFor(b.miner))+b.j};}); if(fx>G){x.fillStyle='rgba(242,84,27,0.06)';x.fillRect(G,0,fx-G,H);x.strokeStyle='rgba(255,179,92,0.55)';x.lineWidth=1;x.setLineDash([4,5]);x.beginPath();x.moveTo(fx,4);x.lineTo(fx,H-4);x.stroke();x.setLineDash([]);}
// edges // lane rails
blocks.forEach(function(b){var p=pos[b.hash];b.parents.forEach(function(h){var q=pos[h];if(!q)return;var both=b.chain&&byHash[h]&&byHash[h].chain; x.strokeStyle='rgba(42,42,48,0.75)';x.lineWidth=1;x.beginPath();for(i=0;i<laneN;i++){var y=Math.round(laneY(i))+0.5;x.moveTo(G,y);x.lineTo(W,y);}x.stroke();
x.strokeStyle=both?'rgba(242,84,27,0.75)':'rgba(201,199,194,0.45)';x.lineWidth=both?2:1.25;x.beginPath();x.moveTo(p.x-S/2,p.y);var mx=(p.x+q.x)/2;x.bezierCurveTo(mx,p.y,mx,q.y,q.x+S/2,q.y);x.stroke();});}); x.save();x.beginPath();x.rect(G,0,W-G,H);x.clip();
// lock line: everything left of the newest locked checkpoint is final (mirrors the homepage simulation) // side links: each side block to its first parent on screen, faint and short
var lk=null;blocks.forEach(function(b){if(b.locked&&(!lk||b.ts>lk.ts))lk=b;}); x.strokeStyle='rgba(154,154,158,0.3)';x.lineWidth=1;x.beginPath();
if(lk){var lp=pos[lk.hash];x.strokeStyle='rgba(242,84,27,0.55)';x.lineWidth=1.5;x.setLineDash([5,6]);x.beginPath();x.moveTo(lp.x,6);x.lineTo(lp.x,H-6);x.stroke();x.setLineDash([]);x.fillStyle='rgba(154,154,158,0.9)';x.font='500 10px IBM Plex Mono, monospace';x.textAlign='right';x.fillText('final',lp.x-8,14);} for(i=0;i<n;i++){b=blocks[i];if(b.chain||!b.on)continue;var q=byHash[b.parents[0]];if(q&&q.px>G-S*2)link(q,b);}
x.stroke();
// the selected chain as one path through the chain blocks
x.strokeStyle=level?'rgba(242,84,27,0.6)':'rgba(242,84,27,0.85)';x.lineWidth=narrow||level?1.5:2;x.beginPath();var prev=null;
for(i=0;i<n;i++){b=blocks[i];if(!b.chain)continue;if(prev&&b.px>G-S*2)link(prev,b);prev=b;}
x.stroke();
// hovered block: all of its parent edges, bright
var hv=hit();
if(hv){x.strokeStyle='rgba(255,179,92,0.9)';x.lineWidth=1.5;x.beginPath();for(i=0;i<hv.parents.length;i++){var pq=byHash[hv.parents[i]];if(pq)link(pq,hv);}x.stroke();}
// blocks // blocks
blocks.forEach(function(b){var p=pos[b.hash],h=S/2,age=now-b.born,fl=Math.max(0,1-age/900); var h=S/2,r=S*0.24,tip=null;
if(fl>0){var g=x.createRadialGradient(p.x,p.y,0,p.x,p.y,S*1.8);g.addColorStop(0,'rgba(255,179,92,'+(0.55*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(p.x,p.y,S*1.8,0,Math.PI*2);x.fill();} for(i=0;i<n;i++){b=blocks[i];if(!b.on)continue;m=miners[b.miner];var age=now-b.born,fl=age<900?1-age/900:0;
if(b.chain){x.fillStyle='#F2541B';rrect(p.x-h,p.y-h,S,S,S*0.24);x.fill();} if(fl>0){var g=x.createRadialGradient(b.px,b.py,0,b.px,b.py,S*1.6);g.addColorStop(0,'rgba(255,179,92,'+(0.5*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(b.px,b.py,S*1.6,0,Math.PI*2);x.fill();}
else{x.fillStyle='#0C0C0E';rrect(p.x-h,p.y-h,S,S,S*0.24);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':'rgba(154,154,158,0.9)';x.lineWidth=2;rrect(p.x-h,p.y-h,S,S,S*0.24);x.stroke();} if(b.chain){x.fillStyle='#F2541B';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=m?m.col:'#9A9A9E';x.lineWidth=1.5;rrect(b.px-h-1,b.py-h-1,S+2,S+2,r+1);x.stroke();tip=b;}
if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(p.x-h-2,p.y-h-2,S+4,S+4,S*0.3);x.stroke();} else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':'rgba(154,154,158,0.85)';x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();}
if(b.locked){x.strokeStyle='#F2541B';x.lineWidth=3;x.beginPath();x.arc(p.x,p.y,S*0.95,0,Math.PI*2);x.stroke();} if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}
x.fillStyle=b.chain?'rgba(244,241,236,0.9)':'rgba(154,154,158,0.9)';x.font='500 '+(S<20?8:9)+'px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(b.miner,p.x,p.y+h+11);}); if(b.locked){x.strokeStyle='#FFB35C';x.lineWidth=2.5;x.beginPath();x.arc(b.px,b.py,S*0.95,0,Math.PI*2);x.stroke();}
// tip marker if(hv===b){x.strokeStyle='#F4F1EC';x.lineWidth=1.5;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}}
if(blocks.length){var tip=blocks[blocks.length-1],tp=pos[tip.hash];x.fillStyle='rgba(154,154,158,0.9)';x.font='500 10px IBM Plex Mono, monospace';x.textAlign='right';x.fillText('tip',Math.min(W-6,tp.x+S*0.5),Math.max(12,tp.y-S*0.75));} x.font='500 10px IBM Plex Mono, monospace';
if(!blocks.length){x.fillStyle='rgba(154,154,158,0.9)';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(live?'waiting for the first block':'no observer data',W/2,H/2);} // the chain tip carries its short id; nothing else is labelled unless hovered, pinned or locked
if(tip){x.fillStyle='rgba(244,241,236,0.92)';x.textAlign='right';var ly=tip.py-h-6;if(ly<10){ly=tip.py+h+12;}x.fillText(tip.hash.slice(0,8),Math.min(W-4,tip.px+h+2),ly);}
if(fx>G){var ft=lk?'final #'+lk.hash.slice(0,8):'final',fw=x.measureText(ft).width;x.fillStyle='#FFB35C';
if(fx-G>fw+14){x.textAlign='right';x.fillText(ft,fx-6,12);}else{x.textAlign='left';x.fillText(ft,fx+6,12);}}
// tooltip on the canvas, no DOM
if(hv){var l1=hv.hash.slice(0,8)+(hv.locked?' locked':(hv.chain?' chain':' side')),l2='blue '+compact(hv.blue_score)+' daa '+compact(hv.daa),l3=hv.parents.length+(hv.parents.length===1?' parent':' parents')+' '+hv.miner;
var tw=Math.max(x.measureText(l1).width,x.measureText(l2).width,x.measureText(l3).width)+20,th=52,tx=hv.px+h+10,tty=hv.py-th/2;
if(tx+tw>W-4)tx=hv.px-h-10-tw;if(tx<G+4)tx=G+4;tty=Math.max(4,Math.min(H-th-4,tty));
x.fillStyle='rgba(22,22,26,0.96)';rrect(tx,tty,tw,th,6);x.fill();x.strokeStyle=miners[hv.miner]?miners[hv.miner].col:'#3A3A42';x.lineWidth=1;rrect(tx+0.5,tty+0.5,tw-1,th-1,6);x.stroke();
x.textAlign='left';x.fillStyle='#F4F1EC';x.fillText(l1,tx+10,tty+16);x.fillStyle='#C9C7C2';x.fillText(l2,tx+10,tty+31);x.fillStyle='#9A9A9E';x.fillText(l3,tx+10,tty+46);}
x.restore();
// gutter: lane labels in the miner's colour, over anything that scrolled under it
x.fillStyle='#0C0C0E';x.fillRect(0,0,G,H);x.strokeStyle='rgba(42,42,48,0.9)';x.lineWidth=1;x.beginPath();x.moveTo(G-0.5,0);x.lineTo(G-0.5,H);x.stroke();
x.textAlign='left';x.font='500 '+(narrow?10:11)+'px IBM Plex Mono, monospace';
for(i=0;i<laneN;i++){var ly2=laneY(i)+4,lm=lanes[i];
if(lm){x.fillStyle=lm.col;x.fillText(narrow?lm.tag:lm.id,8,ly2);}
else{x.fillStyle='rgba(154,154,158,0.9)';x.fillText(narrow?'+'+othersN:'+'+othersN+' more',8,ly2);}}
if(!n){x.fillStyle='rgba(154,154,158,0.9)';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(live?'waiting for the first block':'no observer data',G+(W-G)/2,H/2);}
if(note){x.fillStyle='rgba(12,12,14,0.72)';x.fillRect(0,H/2-22,W,44);x.fillStyle='#F4F1EC';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(note,W/2,H/2+5);} if(note){x.fillStyle='rgba(12,12,14,0.72)';x.fillRect(0,H/2-22,W,44);x.fillStyle='#F4F1EC';x.font='500 13px IBM Plex Mono, monospace';x.textAlign='center';x.fillText(note,W/2,H/2+5);}
} }
function frame(now){draw(now);if(!reduce)requestAnimationFrame(frame);} function frame(now){if(!running)return;draw(now);requestAnimationFrame(frame);}
requestAnimationFrame(frame); function start(){if(running||reduce)return;if(document.hidden||!inView)return;running=true;requestAnimationFrame(frame);}
function stop(){running=false;}
function kick(){if(!running)requestAnimationFrame(function(now){draw(now);});}
document.addEventListener('visibilitychange',function(){if(document.hidden)stop();else start();});
if('IntersectionObserver' in window){new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)start();else stop();},{threshold:0}).observe(c);}
if('ResizeObserver' in window){new ResizeObserver(function(){needSize=true;kick();}).observe(c);}
else window.addEventListener('resize',function(){needSize=true;kick();});
c.addEventListener('pointermove',function(e){if(e.pointerType==='touch')return;ptrX=e.offsetX;ptrY=e.offsetY;pin=null;if(reduce)kick();});
c.addEventListener('pointerleave',function(){ptrX=-1;ptrY=-1;if(reduce)kick();});
c.addEventListener('pointerdown',function(e){if(e.pointerType!=='touch')return;ptrX=e.offsetX;ptrY=e.offsetY;var old=pin;pin=null;var b=hit();pin=b&&b.hash!==old?b.hash:null;ptrX=-1;ptrY=-1;if(reduce)kick();});
start();
// ---------- sparkline ---------- // ---------- sparkline ----------
var sc=$('spark'),sx=sc.getContext('2d'); var sc=$('spark'),sx=sc.getContext('2d');