Homepage: hero dots go live as miners, one shared /api/live poll, live stats strip

The hero canvas now has a live path fed by the same 2 s fetch as the chain scene (one poller, three subscribers: hero, stats strip, chain scene). When the observer is fresh each dot is one miner seen in the last ten minutes (at most 24, no id drawn), it flashes ember when that miner finds a sampled block, and faint lines reach the miners of the block's parents for a moment. Dots join on a new miner and fade out when one leaves the ten-minute set. The drift, colours and pace are the simulation's; the simulation runs unchanged when the observer is off or stale.

A live stats strip under the hero buttons (miners active, blocks / s, network hash rate, blocks on the devnet) shows only while the observer is fresh and hides again when it goes stale. Two tiles per row at phone width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-03 21:38:34 +00:00
parent 47b7aff21f
commit 08d45f82b9

View file

@ -92,6 +92,16 @@ p{margin:0}
.hero-copy{display:flex;flex-direction:column;gap:24px}
.hero p.lead{font-size:clamp(17px,2vw,20px);color:var(--ink-2);max-width:56ch}
.cta{display:flex;flex-wrap:wrap;gap:12px}
/* live stats strip: hidden until the observer reports fresh numbers */
.livestrip{display:none;flex-direction:column;gap:10px}
.livestrip.on{display:flex}
.livestrip-head{display:flex;align-items:center;gap:8px;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--molten)}
.livestrip-head a{color:var(--molten)}
.livestrip-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
.livestrip .tile{padding:14px 16px;border-radius:12px;min-width:0}
.livestrip .tile .v{font-size:18px;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.livestrip .tile .k{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin-top:4px;line-height:1.35}
@media (max-width:700px){.livestrip-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.verify{display:flex;flex-direction:column;gap:18px}
.verify-head{display:flex;justify-content:space-between;align-items:center;gap:12px}
.live{display:inline-flex;align-items:center;gap:8px;font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--molten)}
@ -241,6 +251,15 @@ footer .wrap{padding-block:48px 32px}
<a href="#mine" class="btn primary">Start mining</a>
<a href="/litepaper" class="btn">Read the litepaper</a>
</div>
<div class="livestrip" id="livestrip" aria-live="off">
<div class="livestrip-head"><span class="dot"></span><a href="/live">live devnet</a></div>
<div class="livestrip-grid">
<div class="tile"><div class="v" id="ls-miners">0</div><div class="k">miners active</div></div>
<div class="tile"><div class="v" id="ls-bps">0.0</div><div class="k">blocks / s</div></div>
<div class="tile"><div class="v" id="ls-hash">0.0 MH/s</div><div class="k">network hash rate</div></div>
<div class="tile"><div class="v" id="ls-blocks">0</div><div class="k">blocks on the devnet</div></div>
</div>
</div>
</div>
<div class="card verify reveal">
<div class="verify-head">
@ -485,31 +504,99 @@ footer .wrap{padding-block:48px 32px}
var bio=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){bar.querySelectorAll('div').forEach(function(d){d.style.width=d.getAttribute('data-w');});bio.disconnect();}});},{threshold:.4});
bio.observe(bar);
// hero background: a block graph that grows left to right, proves, and locks
// live observer: ONE fetch of /api/live every 2 s feeds the hero, the stats strip and the chain scene.
// Subscribers get (fresh, d): the sampled new blocks since the last pull and the whole response, or ([], null) when off or stale.
var live={on:false,rate:1.3,seen:{},minerOf:{},subs:[]};
live.sub=function(fn){live.subs.push(fn);};
function emitLive(fresh,d){live.subs.forEach(function(fn){fn(fresh,d);});}
function pullLive(first){return fetch('/api/live',{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(d){
if(!d||!d.ok||!d.state||d.state.stale||!d.blocks||!d.miners){live.on=false;emitLive([],null);return false;}
live.on=true;
var fresh=d.blocks.filter(function(b){return !live.seen[b.hash];});if(first)fresh=fresh.slice(-12);
// the response is a 90 s window, so both maps are rebuilt from it and stay bounded
var seen={},minerOf={};d.blocks.forEach(function(b){seen[b.hash]=1;minerOf[b.hash]=b.miner||'?';});live.seen=seen;live.minerOf=minerOf;
/* the scenes keep a calm pace; faster chains are sampled, the counters stay true */
if(fresh.length>3){var step=fresh.length/3,pick=[];for(var i=0;i<3;i++)pick.push(fresh[Math.floor(i*step)]);fresh=pick;}
emitLive(fresh,d);return true;
}).catch(function(){live.on=false;emitLive([],null);return false;});}
// live stats strip under the hero buttons: shown only while the observer is fresh
var strip=document.getElementById('livestrip');
function lsSet(id,v){var el=document.getElementById(id);if(el)el.textContent=v;}
function fmtHash(h){if(h===null||h===undefined||!(h>0))return 'pending';return h>=1e9?(h/1e9).toFixed(1)+' GH/s':(h/1e6).toFixed(1)+' MH/s';}
live.sub(function(fresh,d){var on=live.on&&!!d;if(strip)strip.classList.toggle('on',on);if(!on)return;var s=d.state;
lsSet('ls-miners',s.miners_10m!==null&&s.miners_10m!==undefined?s.miners_10m:d.miners.length);
lsSet('ls-bps',s.blocks_60s!==null&&s.blocks_60s!==undefined?(s.blocks_60s/60).toFixed(1):'pending');
lsSet('ls-hash',fmtHash(s.hashes_per_second_estimate));
lsSet('ls-blocks',s.block_count!==null&&s.block_count!==undefined?Number(s.block_count).toLocaleString('en-GB'):'pending');});
// hero background: a block graph that grows left to right, proves, and locks.
// Live: each dot is one miner seen in the last ten minutes (at most 24, no ids drawn); it flashes when that
// miner finds a sampled block and a faint line joins it to the miners of the block's parents for a moment.
var cv=document.getElementById('bg'),ctx=cv.getContext('2d'),nodes=[],W,H,dpr=Math.min(window.devicePixelRatio||1,2),lanes=5,count=0;
function size(){W=cv.clientWidth;H=cv.clientHeight;cv.width=W*dpr;cv.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);}
size();window.addEventListener('resize',size);
var spacing=function(){return W<700?74:92;};
function spawn(x){count++;var lane=Math.floor(Math.random()*lanes);var y=H*(0.18+lane*(0.64/(lanes-1)))+(Math.random()*18-9);
function laneY(l){return H*(0.18+l*(0.64/(lanes-1)));}
function edge(a,b){ctx.beginPath();ctx.moveTo(a.x,a.y);var mx=(a.x+b.x)/2;ctx.bezierCurveTo(mx,a.y,mx,b.y,b.x,b.y);ctx.stroke();}
function spawn(x){count++;var lane=Math.floor(Math.random()*lanes);var y=laneY(lane)+(Math.random()*18-9);
var cands=nodes.slice(-7).filter(function(p){return p.x<x-30;});var parents=[];
cands.sort(function(a,b){return b.x-a.x;});if(cands.length)parents.push(cands[0]);
cands.slice(1).forEach(function(p){if(Math.random()<0.45&&parents.length<3)parents.push(p);});
nodes.push({x:x,y:y,born:performance.now(),state:0,lock:count%10===0,parents:parents});if(nodes.length>60)nodes.shift();}
function seed(){nodes=[];count=0;var sp=spacing();for(var i=0;i<Math.ceil(W/sp)+2;i++){spawn(W-i*sp);nodes[nodes.length-1].born=performance.now()-i*1000;}}
var last=0,speed=0.22;
// live state: hm = miner dots, links = parent lines, hq = sampled blocks waiting to flash, simA = simulation opacity
var hm=[],links=[],hq=[],hLive=false,simA=1,hLast=0,MAXDOTS=24;
function minerDot(id){for(var i=0;i<hm.length;i++)if(hm[i].id===id)return hm[i];return null;}
function joinMiner(id){var m=minerDot(id);if(m){m.gone=false;return m;}
var used={};hm.forEach(function(q){used[q.slot]=1;});var s=-1;for(var i=0;i<MAXDOTS;i++)if(!used[i]){s=i;break;}if(s<0)return null;
m={id:id,slot:s,fx:0.06+0.88*((s*0.618034)%1),lane:s%lanes,jy:Math.random()*18-9,ph:Math.random()*Math.PI*2,a:0,gone:false,flash:0,x:0,y:0};hm.push(m);return m;}
live.sub(function(fresh,d){
if(reduce)return;
var on=live.on&&!!d;
if(on!==hLive){hLive=on;if(!on){hm.forEach(function(m){m.gone=true;});hq=[];}}
if(!on)return;
var keep={};d.miners.slice(0,MAXDOTS).forEach(function(m){if(!m.id)return;keep[m.id]=1;joinMiner(m.id);});
hm.forEach(function(m){if(!keep[m.id])m.gone=true;}); // not seen in ten minutes: fade out
fresh.forEach(function(b){hq.push(b);});if(hq.length>6)hq=hq.slice(-6);
});
function frame(t){
if(t-last>1000){spawn(W+16);last=t;}
ctx.clearRect(0,0,W,H);
nodes.forEach(function(n){n.x-=speed;var age=t-n.born;if(age>3500)n.state=2;else if(age>1800)n.state=1;});
// simulation: unchanged while the observer is off or stale, fades out behind the live dots when it is on
simA=Math.max(0,Math.min(1,simA+(hLive?-0.02:0.02)));
if(!hLive){if(!nodes.length)seed();if(t-last>1000){spawn(W+16);last=t;}}
if(simA>0){
ctx.globalAlpha=simA;
nodes.forEach(function(n){n.x-=speed;var age=t-n.born;if(age>3500)n.state=2;else if(age>1800)n.state=1;});
ctx.lineWidth=1;
nodes.forEach(function(n){n.parents.forEach(function(p){if(nodes.indexOf(p)<0)return;var proven=n.state===2&&p.state===2;ctx.strokeStyle=proven?'rgba(242,84,27,0.35)':'rgba(154,154,158,0.18)';edge(n,p);});});
nodes.forEach(function(n){var r=n.state===2?5:4;
if(n.state===2&&n.lock){ctx.beginPath();ctx.arc(n.x,n.y,11,0,Math.PI*2);ctx.strokeStyle='rgba(242,84,27,0.9)';ctx.lineWidth=1.5;ctx.stroke();ctx.lineWidth=1;}
if(n.state===2){var g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,16);g.addColorStop(0,'rgba(242,84,27,0.35)');g.addColorStop(1,'rgba(242,84,27,0)');ctx.fillStyle=g;ctx.beginPath();ctx.arc(n.x,n.y,16,0,Math.PI*2);ctx.fill();}
ctx.beginPath();ctx.arc(n.x,n.y,r,0,Math.PI*2);ctx.fillStyle=n.state===2?'#F2541B':(n.state===1?'#FFB35C':'#0C0C0E');ctx.fill();
if(n.state===0){ctx.strokeStyle='rgba(154,154,158,0.8)';ctx.stroke();}});
ctx.globalAlpha=1;
} else if(nodes.length) nodes=[];
// live: release one sampled block at the chain scene's pace; its miner flashes, lines reach its parents' miners
if(hLive&&hq.length&&t-hLast>Math.max(60,1000/live.rate)){var b=hq.shift();hLast=t;var m=joinMiner(b.miner||'?');
if(m){m.flash=1;var done={};(b.parents||[]).forEach(function(h){var pid=live.minerOf[h];if(!pid||pid===m.id||done[pid])return;done[pid]=1;var p=minerDot(pid);if(p&&!p.gone)links.push({a:m,b:p,born:t});});if(links.length>24)links=links.slice(-24);}}
hm.forEach(function(m){m.a=Math.max(0,Math.min(1,m.a+(m.gone?-0.012:0.012)));
m.fx-=speed/W;if(m.fx<-0.02){m.fx+=1.04;m.a=0;} // the same slow drift; a dot that leaves on the left fades back in on the right
m.x=W*m.fx+Math.sin(t/9000+m.ph)*14;m.y=laneY(m.lane)+m.jy+Math.cos(t/12000+m.ph*1.7)*6;
if(m.flash>0)m.flash=Math.max(0,m.flash-0.018);});
hm=hm.filter(function(m){return !(m.gone&&m.a<=0);});
links=links.filter(function(l){return t-l.born<1600&&hm.indexOf(l.a)>=0&&hm.indexOf(l.b)>=0;});
ctx.lineWidth=1;
nodes.forEach(function(n){n.parents.forEach(function(p){if(nodes.indexOf(p)<0)return;var proven=n.state===2&&p.state===2;ctx.strokeStyle=proven?'rgba(242,84,27,0.35)':'rgba(154,154,158,0.18)';ctx.beginPath();ctx.moveTo(n.x,n.y);var mx=(n.x+p.x)/2;ctx.bezierCurveTo(mx,n.y,mx,p.y,p.x,p.y);ctx.stroke();});});
nodes.forEach(function(n){var r=n.state===2?5:4;
if(n.state===2&&n.lock){ctx.beginPath();ctx.arc(n.x,n.y,11,0,Math.PI*2);ctx.strokeStyle='rgba(242,84,27,0.9)';ctx.lineWidth=1.5;ctx.stroke();ctx.lineWidth=1;}
if(n.state===2){var g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,16);g.addColorStop(0,'rgba(242,84,27,0.35)');g.addColorStop(1,'rgba(242,84,27,0)');ctx.fillStyle=g;ctx.beginPath();ctx.arc(n.x,n.y,16,0,Math.PI*2);ctx.fill();}
ctx.beginPath();ctx.arc(n.x,n.y,r,0,Math.PI*2);ctx.fillStyle=n.state===2?'#F2541B':(n.state===1?'#FFB35C':'#0C0C0E');ctx.fill();
if(n.state===0){ctx.strokeStyle='rgba(154,154,158,0.8)';ctx.stroke();}});
links.forEach(function(l){if(Math.abs(l.a.x-l.b.x)>W*0.6)return;var p=(t-l.born)/1600;ctx.strokeStyle='rgba(242,84,27,'+(0.35*(1-p)*Math.min(l.a.a,l.b.a)).toFixed(3)+')';edge(l.a,l.b);});
hm.forEach(function(m){if(m.a<=0)return;var f=m.flash;ctx.globalAlpha=m.a;var gr=16+8*f;
var g=ctx.createRadialGradient(m.x,m.y,0,m.x,m.y,gr);g.addColorStop(0,'rgba(242,84,27,'+(0.2+0.35*f).toFixed(3)+')');g.addColorStop(1,'rgba(242,84,27,0)');ctx.fillStyle=g;ctx.beginPath();ctx.arc(m.x,m.y,gr,0,Math.PI*2);ctx.fill();
ctx.beginPath();ctx.arc(m.x,m.y,4+2*f,0,Math.PI*2);ctx.fillStyle='#F2541B';ctx.fill();
if(f>0){ctx.beginPath();ctx.arc(m.x,m.y,2+1.5*f,0,Math.PI*2);ctx.fillStyle='rgba(255,179,92,'+f.toFixed(3)+')';ctx.fill();}
ctx.globalAlpha=1;});
requestAnimationFrame(frame);
}
if(!reduce){var sp=spacing();for(var i=0;i<Math.ceil(W/sp)+2;i++){spawn(W-i*sp);nodes[nodes.length-1].born=performance.now()-i*1000;}requestAnimationFrame(frame);}
if(!reduce){seed();requestAnimationFrame(frame);}
// chain scene: blocks stream in, miners fly in to prove shards, locks ripple
(function(){
@ -518,8 +605,8 @@ footer .wrap{padding-block:48px 32px}
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?18:26;GAP=W<600?S*2.5:S*3.1;speed=GAP/1000;k=Math.max(0.22,Math.min(1,(W/speed)/22000));}
size();window.addEventListener('resize',size);
function laneY(l){return H*0.14+l*(H*0.72/(lanes-1));}
// live path state: when the devnet observer is fresh, real blocks (lb) replace the random spawner
var liveMode=false,liveQ=[],liveSeen={},liveLanes={},liveRate=1,byHash={};
// live path state: when the devnet observer is fresh, real blocks (lb) from the shared poller replace the random spawner
var liveMode=false,liveQ=[],liveLanes={},byHash={};
function spawn(px,lb){n++;var lane=lb?lb.lane:Math.floor(Math.random()*lanes);var b={id:n,x:px,y:laneY(lane),born:performance.now(),shards:[0,0,0,0],claimed:[false,false,false,false],state:'mined',cp:n%6===0,parents:[],glow:0,flash:0,hash:lb?lb.hash:null};
if(lb){byHash[lb.hash]=b;lb.parents.forEach(function(h){var q=byHash[h];if(q&&blocks.indexOf(q)>=0&&b.parents.length<4)b.parents.push(q);});if(!b.parents.length){var prev=blocks.slice(-6).filter(function(q){return q.x<px-S;}).sort(function(a2,b2){return b2.x-a2.x;});if(prev.length)b.parents.push(prev[0]);if(prev.length>1&&Math.random()<0.5)b.parents.push(prev[1]);}}
else{var cands=blocks.slice(-8).filter(function(q){return q.x<px-S;}).sort(function(a2,b2){return b2.x-a2.x;});
@ -528,10 +615,10 @@ footer .wrap{padding-block:48px 32px}
function rr(px,py,w,h,r){x.beginPath();x.moveTo(px+r,py);x.arcTo(px+w,py,px+w,py+h,r);x.arcTo(px+w,py+h,px,py+h,r);x.arcTo(px,py+h,px,py,r);x.arcTo(px,py,px+w,py,r);x.closePath();}
var last=0,lastShard=0;
function frame(t){
if(liveMode){if(liveQ.length&&t-last>Math.max(60,1000/liveRate)){spawn(W+S,liveQ.shift());last=t;}}
if(liveMode){if(liveQ.length&&t-last>Math.max(60,1000/live.rate)){spawn(W+S,liveQ.shift());last=t;}}
else if(t-last>1000){spawn(W+S);last=t;}
var dt=16;
var sp=liveMode?speed*liveRate:speed;blocks.forEach(function(b){b.x-=sp*dt;var age=t-b.born;
var sp=liveMode?speed*live.rate:speed;blocks.forEach(function(b){b.x-=sp*dt;var age=t-b.born;
if(b.state==='mined'&&age>1200*k)b.state='proving';
if(b.state==='proving'){for(var i=0;i<4;i++){if(!b.claimed[i]&&Math.random()<0.012/k){b.claimed[i]=true;var fromTop=Math.random()<0.5;sparks.push({x:Math.random()*W,y:fromTop?-6:H+6,tx:b.x,ty:b.y,b:b,i:i,p:0});}}
if(b.shards.every(function(v){return v>=1;})){b.state='proven';b.glow=1;proven++;cp.textContent=proven;if(b.cp)setTimeout(function(){b.locked=true;locked++;cl.textContent=locked;ripples.push({x:b.x,y:b.y,r:S,a:1});ripples.push({x:b.x,y:b.y,r:S*0.5,a:1});},1800*k);}}
@ -563,21 +650,16 @@ footer .wrap{padding-block:48px 32px}
var seedN=Math.ceil(W/GAP)+1,pv=Math.min(5,Math.max(2,Math.floor(seedN*0.45)));for(var i=0;i<seedN;i++){spawn(W-i*GAP);var b=blocks[blocks.length-1];b.born=performance.now()-i*1000;if(i>pv){b.state='proven';b.shards=[1,1,1,1];b.claimed=[true,true,true,true];proven++;if(b.cp){b.locked=true;locked++;}}else if(i>1){b.state='proving';b.claimed=[true,true,false,false];b.shards=[1,1,0,0];}}
cb.textContent=n;cp.textContent=proven;cl.textContent=locked;
requestAnimationFrame(frame);
// live path: one fetch on load; if the observer updated within 30 s, the label links to /live and real blocks feed the scene
// live path: the shared poller above feeds sampled real blocks; while fresh, the label links to /live
var label=document.getElementById('viz-label'),labelHTML=label?label.innerHTML:'';
function laneFor(m){if(!(m in liveLanes))liveLanes[m]=Object.keys(liveLanes).length%lanes;return liveLanes[m];}
function setLive(on){if(on===liveMode)return;liveMode=on;if(label)label.innerHTML=on?'<span class="dot" style="margin-right:8px"></span><a href="/live" style="color:var(--molten)">live devnet</a>':labelHTML;}
function pullLive(first){return fetch('/api/live',{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(d){
if(!d||!d.ok||!d.state||d.state.stale){setLive(false);return false;}
setLive(true);liveRate=1.3; /* the scene keeps a calm pace; faster chains are sampled, the counter stays true */
var fresh=d.blocks.filter(function(b){return !liveSeen[b.hash];});if(first)fresh=fresh.slice(-12);
d.blocks.forEach(function(b){liveSeen[b.hash]=1;});
if(fresh.length>3){var step=fresh.length/3,pick=[];for(var i=0;i<3;i++)pick.push(fresh[Math.floor(i*step)]);fresh=pick;}
fresh.forEach(function(b){liveQ.push({hash:b.hash,parents:b.parents,lane:laneFor(b.miner||'?')});});
if(liveQ.length>6)liveQ=liveQ.slice(-6);return true;
}).catch(function(){setLive(false);return false;});}
pullLive(true).then(function(on){if(on)setInterval(function(){pullLive(false);},2000);});
live.sub(function(fresh){setLive(live.on);if(!live.on)return;
fresh.forEach(function(b){liveQ.push({hash:b.hash,parents:b.parents||[],lane:laneFor(b.miner||'?')});});
if(liveQ.length>6)liveQ=liveQ.slice(-6);});
})();
// one fetch on load; only if the observer is fresh does the 2 s poll start (it feeds the hero, the strip and the chain scene)
pullLive(true).then(function(on){if(on)setInterval(function(){pullLive(false);},2000);});
// hourly program ticker
var ops=['rotl','rotr','xor','add','sub','mul','mulhi','mad','or'];