Live DAG: chain rail layout, time ruler, slide-in, red as ember tint, pending count

The selected chain runs along one horizontal rail; crowded chain blocks
stagger to three rows around it. Side blocks hang above and below in time
order, packed into rows so none overlap. The miner is the ring colour; the
gutter lists active miners with their on-screen block counts. A ruler under
the blocks ticks every 5 s with clock labels and now at the right edge. New
blocks slide in from the right. Red blocks are a dim ember tint. Header stats
gain a pending count. Density steps, tooltips, final band and the one-rAF
loop are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 10:15:45 +00:00
parent 734dbd134f
commit 3e03e9bff7

View file

@ -176,19 +176,20 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
<div class="card" aria-label="Block DAG, live">
<div class="viz-head">
<div class="eyebrow"><span class="dot" style="margin-right:8px"></span>block dag</div>
<div class="viz-stats mono"><span>on screen <b id="c-screen">0</b></span><span>chain <b id="c-chain">0</b></span><span>side <b id="c-side">0</b></span><span>newest blue <b id="c-newest">0</b></span><span>locked <b id="c-locked">none</b></span></div>
<div class="viz-stats mono"><span>on screen <b id="c-screen">0</b></span><span>chain <b id="c-chain">0</b></span><span>side <b id="c-side">0</b></span><span>pending <b id="c-pending">0</b></span><span>newest blue <b id="c-newest">0</b></span><span>locked <b id="c-locked">none</b></span></div>
</div>
<canvas id="dag" aria-hidden="true"></canvas>
<div class="legend">
<span><i class="sw" style="background:var(--ember);border:1.5px solid #5CB8FF"></i>chain block</span>
<span><i class="sw" style="background:rgba(92,184,255,.7);border:1.5px solid #5CB8FF"></i>included (blue): paid</span>
<span><i class="sw" style="border:1.5px solid var(--ash)"></i>pending</span>
<span><i class="sw" style="border:1.5px solid rgba(154,154,158,.6);background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat"></i>excluded (red)</span>
<span><i class="sw" style="border:1.5px solid rgba(242,84,27,.5);background:rgba(242,84,27,.22)"></i>excluded (red)</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="width:22px;height:0;border-top:2px solid var(--ember)"></i>selected chain</span>
<span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>first parent</span>
<span><span class="mono" style="color:#5CB8FF">5d3fd79c</span>one lane per miner, colour from its id</span>
<span><span class="mono" style="color:var(--ash)">12:34</span>ruler, a tick every 5 s</span>
<span><span class="mono" style="color:#5CB8FF">5d3fd79c</span>ring colour = miner; the gutter counts each miner's blocks on screen</span>
<span style="color:var(--ash)">hover or tap a block for its id, scores and parents</span>
</div>
</div>
@ -263,29 +264,29 @@ 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;');}
// ---------- DAG scene ----------
// One canvas, one requestAnimationFrame loop. Lanes: one per miner in the visible window, most blocks on top,
// the least active share an "others" lane. Labels live in a left gutter, never on blocks. Edges go under blocks.
var c=$('dag'),x=c.getContext('2d'),W=0,H=0,S=22,G=78,narrow=false,level=0,maxLanes=8;
var blocks=[],byHash={},miners={},lanes=[],laneN=1,othersN=0;
var LV=[{S:22,s:14,pps:40,npps:14},{S:16,s:12,pps:26,npps:10},{S:12,s:10,pps:18,npps:8}];
// One canvas, one requestAnimationFrame loop. The selected chain runs along one horizontal rail; side blocks hang
// above and below it in time order, packed into rows so none overlap. The miner is the ring colour; the left gutter
// lists the active miners with their block counts. A time ruler sits under the blocks. Edges draw under blocks.
var c=$('dag'),x=c.getContext('2d'),W=0,H=0,S=22,G=104,narrow=false,level=0;
var blocks=[],byHash={},miners={},minerList=[],rail=0,maxRows=1,rowGap=33;
var LV=[{S:22,s:14,pps:40,npps:14},{S:16,s:12,pps:26,npps:11},{S:12,s:10,pps:18,npps:9}];
var pxPerSec=40,targetPx=40,bps=0,live=false,serverNow=0,serverAt=0,frozenAt=null,note='';
var running=false,needSize=true,inView=true,ptrX=-1,ptrY=-1,ptrTouch=false,pin=null,laneLast=[],laneRun=[];
var TOP=20,BOT=12;
var running=false,needSize=true,inView=true,ptrX=-1,ptrY=-1,pin=null,above=[],below=[],crow=[],CO=[0,-1.1,1.1],tickLabels={},tickCount=0;
var TOP=18,BOT=4,RUL=20,SLIDE=450;
// 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 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();
narrow=W<560;G=narrow?54:104;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();
rowGap=S*1.5;rail=TOP+(H-TOP-RUL-BOT)/2;
maxRows=Math.max(1,Math.floor((rail-TOP-S*2.8)/rowGap)+1);
}
function pickLevel(){
// projected blocks on screen at the base scale drives the step, with hysteresis so it never flickers
@ -293,16 +294,6 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
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){
var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now;
d.blocks.forEach(function(b){var e=byHash[b.hash];
@ -313,19 +304,20 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
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;});
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];
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)};}m.n++;});
minerList=[];for(id in miners){if(!miners[id].n)delete miners[id];else minerList.push(miners[id]);}
minerList.sort(function(a,b){return b.n-a.n||(a.id<b.id?-1:1);});
bps=d.state.blocks_per_second_60s||0;
pickLevel();layoutLanes();
var vis=vnow()-((W-G)/Math.max(8,targetPx))*1000,on=0,chain=0;
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;
pickLevel();
var vis=vnow()-((W-G)/Math.max(8,targetPx))*1000,on=0,chain=0,pend=0;
blocks.forEach(function(b){if(b.ts>vis){on++;if(b.chain)chain++;else if(b.color==='pending')pend++;}});
$('c-screen').textContent=on;$('c-chain').textContent=chain;$('c-side').textContent=on-chain;$('c-pending').textContent=pend;
$('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');
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();}
// a cubic with horizontal tangents; the control offset is clamped so a link across many lanes reads as a gentle S, never a loop
// a cubic with horizontal tangents; the control offset is clamped so a link across many rows 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;
@ -334,30 +326,40 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
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 tickLabel(tt){var s=tickLabels[tt];if(s)return s;if(tickCount>240){tickLabels={};tickCount=0;}var d=new Date(tt);s=(d.getMinutes()<10?'0':'')+d.getMinutes()+':'+(d.getSeconds()<10?'0':'')+d.getSeconds();tickLabels[tt]=s;tickCount++;return s;}
function draw(now){
if(needSize)size();if(!W||!H)return;
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;}
// positions: time on x, the chain on the rail; side blocks hang above or below it in time order, packed into rows
above.length=0;below.length=0;crow.length=0;
for(i=0;i<n;i++){b=blocks[i];var tx=Math.min(xr,xr-(t-b.ts)*pxPerSec/1000),r=0;
// chain blocks sit on the rail; when they crowd, they stagger above and below it in a fixed sequence
if(b.chain){while(r<2&&crow[r]!==undefined&&tx-crow[r]<S*1.15)r++;crow[r]=tx;b.py=rail+CO[r]*S;}
else{var rows=b.j<0?above:below;while(r<maxRows-1&&rows[r]!==undefined&&tx-rows[r]<S*1.3)r++;rows[r]=tx;b.py=rail+(b.j<0?-1:1)*(S*2.3+r*rowGap);}
var age=now-b.born;if(age<SLIDE){var k=1-age/SLIDE;tx=tx+(W+S-tx)*k*k*k;}
b.px=tx;b.on=tx>G-S;}
x.clearRect(0,0,W,H);
var bottom=H-RUL-BOT;
// final region: everything left of the newest locked checkpoint
var lk=null,fx=-1;
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;}
if(lk)fx=lk.px;
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([]);}
// lane rails
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.save();x.beginPath();x.rect(G,0,W-G,H);x.clip();
if(fx>G){x.fillStyle='rgba(242,84,27,0.06)';x.fillRect(G,0,fx-G,bottom);x.strokeStyle='rgba(255,179,92,0.55)';x.lineWidth=1;x.setLineDash([4,5]);x.beginPath();x.moveTo(fx,4);x.lineTo(fx,bottom);x.stroke();x.setLineDash([]);}
// the rail
x.strokeStyle='rgba(58,58,66,0.9)';x.lineWidth=1;x.beginPath();x.moveTo(G,Math.round(rail)+0.5);x.lineTo(W,Math.round(rail)+0.5);x.stroke();
// time ruler: a tick every 5 s, labelled with the clock, "now" at the right edge
var ry=H-RUL+0.5;x.strokeStyle='rgba(58,58,66,0.9)';x.beginPath();x.moveTo(G,ry);x.lineTo(W,ry);x.stroke();
x.font='500 9px IBM Plex Mono, monospace';x.textAlign='center';x.fillStyle='rgba(154,154,158,0.75)';x.strokeStyle='rgba(90,90,98,0.9)';x.beginPath();
var every=narrow?10000:5000,tt=Math.floor(t/5000)*5000,labelFrom=W-34;
for(;;tt-=5000){var xx=xr-(t-tt)*pxPerSec/1000;if(xx<G+2)break;var major=tt%every===0;x.moveTo(Math.round(xx)+0.5,ry);x.lineTo(Math.round(xx)+0.5,ry+(major?5:3));if(major&&xx<labelFrom&&xx>G+16)x.fillText(tickLabel(tt),xx,H-2);}
x.stroke();x.textAlign='right';x.fillStyle='rgba(255,179,92,0.85)';x.fillText('now',W-3,H-2);
x.save();x.beginPath();x.rect(G,0,W-G,bottom);x.clip();
// side links: each side block to its first parent on screen, faint and short
x.strokeStyle='rgba(154,154,158,0.3)';x.lineWidth=1;x.beginPath();
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
// the selected chain as one path along the rail
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();
@ -366,34 +368,36 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
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
var h=S/2,r=S*0.24,tip=null;
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;
for(i=0;i<n;i++){b=blocks[i];if(!b.on)continue;m=miners[b.miner];var age2=now-b.born,fl=age2<900?1-age2/900:0,mc=m?m.col:'#9A9A9E';
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();}
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;}
else if(b.color==='blue'){var mc=m?m.col:'#9A9A9E';x.fillStyle=mc+'B3';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':mc;x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);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)':(b.color==='red'?'rgba(154,154,158,0.6)':'rgba(154,154,158,0.85)');x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();
if(b.color==='red'){x.strokeStyle='rgba(154,154,158,0.9)';x.lineWidth=1.5;x.beginPath();x.moveTo(b.px-h*0.5,b.py+h*0.5);x.lineTo(b.px+h*0.5,b.py-h*0.5);x.stroke();}}
if(b.chain){x.fillStyle='#F2541B';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=mc;x.lineWidth=1.5;rrect(b.px-h-1,b.py-h-1,S+2,S+2,r+1);x.stroke();tip=b;}
else if(b.color==='blue'){x.fillStyle=mc+'B3';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':mc;x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();}
else if(b.color==='red'){x.fillStyle='rgba(242,84,27,0.22)';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle='rgba(242,84,27,0.5)';x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);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(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();}
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();}
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();}}
x.font='500 10px IBM Plex Mono, monospace';
// 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(tip){x.fillStyle='rgba(244,241,236,0.92)';x.textAlign='right';x.fillText(tip.hash.slice(0,8),Math.min(W-4,tip.px+h+2),tip.py-h-6);}
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':hv.color==='blue'?'included, paid':hv.color==='red'?'excluded':'pending'),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);}
var tw=Math.max(x.measureText(l1).width,x.measureText(l2).width,x.measureText(l3).width)+20,th=52,tx2=hv.px+h+10,tty=hv.py-th/2;
if(tx2+tw>W-4)tx2=hv.px-h-10-tw;if(tx2<G+4)tx2=G+4;tty=Math.max(4,Math.min(bottom-th-4,tty));
x.fillStyle='rgba(22,22,26,0.96)';rrect(tx2,tty,tw,th,6);x.fill();x.strokeStyle=miners[hv.miner]?miners[hv.miner].col:'#3A3A42';x.lineWidth=1;rrect(tx2+0.5,tty+0.5,tw-1,th-1,6);x.stroke();
x.textAlign='left';x.fillStyle='#F4F1EC';x.fillText(l1,tx2+10,tty+16);x.fillStyle='#C9C7C2';x.fillText(l2,tx2+10,tty+31);x.fillStyle='#9A9A9E';x.fillText(l3,tx2+10,tty+46);}
x.restore();
// gutter: lane labels in the miner's colour, over anything that scrolled under it
// gutter: the active miners, most blocks first, short id in the miner's colour and its count in the window
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);}
var lh=narrow?13:15,fs=narrow?10:11,y0=14;
x.textAlign='left';x.font='500 9px IBM Plex Mono, monospace';x.fillStyle='rgba(154,154,158,0.75)';x.fillText(minerList.length+(narrow?' min':' miners'),8,y0);
x.font='500 '+fs+'px IBM Plex Mono, monospace';
var fit=Math.max(1,Math.floor((bottom-y0-12)/lh)),shown=minerList.length>fit?fit-1:minerList.length;
for(i=0;i<shown;i++){m=minerList[i];var yy=y0+12+(i+1)*lh-4;x.textAlign='left';x.fillStyle=m.col;x.fillText(narrow?m.tag:m.id,8,yy);x.textAlign='right';x.fillStyle='rgba(154,154,158,0.9)';x.fillText(String(m.n),G-8,yy);}
if(shown<minerList.length){x.textAlign='left';x.fillStyle='rgba(154,154,158,0.9)';x.fillText('+'+(minerList.length-shown)+(narrow?'':' more'),8,y0+12+(shown+1)*lh-4);}
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,rail);}
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){if(!running)return;draw(now);requestAnimationFrame(frame);}