@@ -683,19 +693,25 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
// 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.reason says why the feed is off, in plain words, for the scene's label: 'connecting', 'live feed unavailable' (the fetch
+ // failed or the function errored), or 'observer offline, last update N s ago' (the observer's row is older than 30 s).
+ var live={on:false,rate:1.3,seen:{},minerOf:{},subs:[],reason:'connecting'};
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;
+ function fmtAge(sec){sec=Math.round(sec);return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
+ function liveOff(reason){live.on=false;live.reason=reason;emitLive([],null);return false;}
+ function pullLive(first){return fetch('/api/live',{cache:'no-store'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){
+ if(!d||!d.ok)return liveOff('live feed unavailable');
+ if(!d.state||!d.blocks||!d.miners)return liveOff('live feed unavailable');
+ if(d.state.stale)return liveOff('observer offline'+(d.state.age_s!==null&&d.state.age_s!==undefined?', last update '+fmtAge(d.state.age_s)+' ago':''));
+ live.on=true;live.reason=null;
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;});}
+ }).catch(function(){return liveOff('live feed unavailable');});}
// live stats strip under the hero buttons: shown only while the observer is fresh
var strip=document.getElementById('livestrip');
@@ -705,7 +721,7 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
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');});
+ lsSet('ls-blocks',s.height!==null&&s.height!==undefined?Number(s.height).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
@@ -784,21 +800,30 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
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) from the shared poller replace the random spawner
var liveMode=false,liveQ=[],liveLanes={},byHash={};
+ // a live block takes the observer's words: color pending / blue (included) / red (excluded), shards planned / proving / verified / paid,
+ // locked = a locked checkpoint block, final = at or before the newest lock. Re-applied on every poll so a block on screen updates.
+ function applyLive(b,src){b.color=src.color||'pending';b.chain=!!src.chain;b.isFinal=!!src.final;var sh=src.shards||[];
+ var done=function(x){return x.state==='verified'||x.state==='paid';};
+ var allProven=sh.length>0&&sh.every(done),anyProving=sh.some(function(x){return x.state==='proving'||done(x);});
+ if(allProven){if(b.state!=='proven'){b.state='proven';b.glow=1;}}else if(anyProving)b.state='proving';else b.state='mined';
+ b.shards=[0,0,0,0];sh.slice(0,4).forEach(function(x,i){b.shards[i]=done(x)?1:0;});
+ if(src.locked&&!b.locked){b.locked=true;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});}}
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
1&&Math.random()<0.5)b.parents.push(prev[1]);}}
+ if(lb){byHash[lb.hash]=b;if(lb.src)applyLive(b,lb.src);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.x1&&Math.random()<0.5)b.parents.push(prev[1]);}}
else{var cands=blocks.slice(-8).filter(function(q){return q.x80){var old=blocks.shift();if(old.hash)delete byHash[old.hash];}cb.textContent=n;}
+ blocks.push(b);if(blocks.length>80){var old=blocks.shift();if(old.hash)delete byHash[old.hash];}if(!liveMode)cb.textContent=n;}
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;
+ var last=0,lastShard=0,prevT=0;
function frame(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;
+ // real frame time, capped: a fixed 16 ms ran the scene at double speed on 120 Hz displays (found 6 Oct 2026)
+ var dt=prevT?Math.min(50,t-prevT):16;prevT=t;
var sp=liveMode?speed*live.rate:speed;blocks.forEach(function(b){b.x-=sp*dt;var age=t-b.born;
if(!liveMode&&b.state==='mined'&&age>1200*k)b.state='proving';
if(!liveMode&&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);}}
+ if(b.shards.every(function(v){return v>=1;})){b.state='proven';b.glow=1;proven++;if(!liveMode)cp.textContent=proven;if(b.cp)setTimeout(function(){b.locked=true;locked++;if(!liveMode)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);}}
if(b.glow>0)b.glow-=0.02;});
sparks.forEach(function(sp){sp.p+=Math.min(0.08,0.035/k);sp.tx=sp.b.x;sp.ty=sp.b.y;if(sp.p>=1){sp.done=true;sp.b.shards[sp.i]=1;}});
sparks=sparks.filter(function(sp){return !sp.done;});
@@ -814,10 +839,11 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
// blocks
blocks.forEach(function(b){var h=S/2;
// proving v0: a faint second glint behind a real block once every shard of it is verified on the devnet (nothing before activation)
- if(b.sealed){var g2=x.createRadialGradient(b.x,b.y,S*0.45,b.x,b.y,S*1.35);g2.addColorStop(0,'rgba(255,179,92,0.18)');g2.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g2;x.beginPath();x.arc(b.x,b.y,S*1.35,0,Math.PI*2);x.fill();}
if(b.glow>0){var g=x.createRadialGradient(b.x,b.y,0,b.x,b.y,S*1.6);g.addColorStop(0,'rgba(242,84,27,'+(0.45*b.glow)+')');g.addColorStop(1,'rgba(242,84,27,0)');x.fillStyle=g;x.beginPath();x.arc(b.x,b.y,S*1.6,0,Math.PI*2);x.fill();}
if(b.state==='proven'){x.fillStyle='#F2541B';rr(b.x-h,b.y-h,S,S,S*0.24);x.fill();}
- else{x.fillStyle='#0C0C0E';rr(b.x-h,b.y-h,S,S,S*0.24);x.fill();x.strokeStyle=b.state==='proving'?'rgba(255,179,92,0.9)':'rgba(58,58,66,1)';x.lineWidth=2;rr(b.x-h,b.y-h,S,S,S*0.24);x.stroke();
+ else{var ex=b.color==='red';if(ex)x.globalAlpha=0.45;x.fillStyle='#0C0C0E';rr(b.x-h,b.y-h,S,S,S*0.24);x.fill();
+ // live: included (blue or on the selected chain) takes an ember outline, excluded (red) is dimmed, pending stays grey
+ x.strokeStyle=b.state==='proving'?'rgba(255,179,92,0.9)':(liveMode&&!ex&&(b.color==='blue'||b.chain))?'rgba(242,84,27,0.75)':'rgba(58,58,66,1)';x.lineWidth=2;rr(b.x-h,b.y-h,S,S,S*0.24);x.stroke();if(ex)x.globalAlpha=1;
if(b.state==='proving'){var q=S/2-3;for(var i=0;i<4;i++){if(b.shards[i]>=1){x.fillStyle='#FFB35C';var qx=b.x-h+3+(i%2)*q,qy=b.y-h+3+Math.floor(i/2)*q;rr(qx,qy,q-1,q-1,2);x.fill();}}}}
if(b.locked){var pulse=0.8+0.2*Math.sin(t/350);x.strokeStyle='rgba(242,84,27,'+pulse+')';x.lineWidth=3;x.beginPath();x.arc(b.x,b.y,S*1.15,0,Math.PI*2);x.stroke();x.fillStyle='#F4F1EC';x.beginPath();x.arc(b.x,b.y,S*0.16,0,Math.PI*2);x.fill();}
});
@@ -830,19 +856,38 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
cb.textContent=n;cp.textContent=proven;cl.textContent=locked;
requestAnimationFrame(frame);
// 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:'';
+ var label=document.getElementById('viz-label'),note=document.getElementById('viz-note'),legSim=document.getElementById('legend-sim'),legLive=document.getElementById('legend-live');
+ var kb=document.getElementById('k-blocks'),kp=document.getElementById('k-proven'),kl=document.getElementById('k-locked');
+ var fmtN=function(v){return v===null||v===undefined?'pending':Number(v).toLocaleString('en-GB');};
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?'live devnet':labelHTML;}
+ // three label states: live (the observer is fresh), off with the reason (the simulation runs and its counters count simulated blocks), connecting
+ function setLive(on){
+ if(on!==liveMode){liveMode=on;if(!on){cb.textContent=n;cp.textContent=proven;cl.textContent=locked;}}
+ if(legSim)legSim.hidden=on;if(legLive)legLive.hidden=!on;
+ if(kb)kb.textContent=on?'chain block':'blocks';if(kp)kp.textContent=on?'shards proven':'proven';if(kl)kl.textContent=on?'last lock':'locked';
+ if(!label)return;
+ if(on){label.innerHTML='live devnet';return;}
+ var r=live.reason||'live feed unavailable';
+ label.innerHTML='simulated preview'+(r==='connecting'?'':' ยท '+r);
+ if(note){note.hidden=false;note.textContent=r==='connecting'?'Connecting to the devnet observer. Until it answers, this is a simulation and its counters count simulated blocks.':r.charAt(0).toUpperCase()+r.slice(1)+'. This is a simulation and its counters count simulated blocks. The live page shows the last stored state.';}}
live.sub(function(fresh,d){setLive(live.on);if(!live.on)return;
- // live counters come from the observer, never from the simulation: proven = blocks in the window every shard of which is verified, locked = locked checkpoints in the window
- if(d&&d.blocks){var pv=d.proving&&d.proving.supported&&d.proving.active;cp.textContent=pv?String(d.blocks.filter(function(b){return b.proven;}).length):'not active';cl.textContent=String(d.blocks.filter(function(b){return b.locked;}).length);}
- fresh.forEach(function(b){liveQ.push({hash:b.hash,parents:b.parents||[],lane:laneFor(b.miner||'?')});});
- if(liveQ.length>6)liveQ=liveQ.slice(-6);
- // the glint above: only the per-block shards data, only while the proving layer is active
- if(d&&d.proving&&d.proving.supported&&d.proving.active)d.blocks.forEach(function(b){if(b.proven){var q=byHash[b.hash];if(q)q.sealed=true;}});});
+ // live counters come from the chain, never from the 90 s window: the chain block number, the shards proven and paid on the chain
+ // as the observer's node counts them (proofs land minutes behind the tip, so the blocks on screen are still pending), the latest locked checkpoint
+ var s=d.state,pv=d.proving,fn=d.finality;
+ cb.textContent=fmtN(s.height);
+ cp.textContent=!pv||!pv.supported?'no proving layer':!pv.active?'not active':fmtN(pv.paid_shards_total);
+ cl.textContent=!fn||!fn.supported?'no finality':!fn.active?'paused':fn.latest_locked_index!==null&&fn.latest_locked_index!==undefined?'#'+fmtN(fn.latest_locked_index):'none yet';
+ if(note){var lag=pv&&pv.active&&pv.median_proof_lag_s?Math.round(pv.median_proof_lag_s):null;
+ note.hidden=false;note.textContent='One node is read every 2 s. Chain block is the EVM block number. Shards proven is every shard paid on the chain so far'+(pv&&pv.active?' ('+fmtN(pv.shards_paid_10m||0)+' in the last 10 minutes'+(lag?', landing a median '+lag+' s behind the tip, so the newest blocks on screen are still pending':'')+')':'')+'. Last lock is the latest checkpoint the finality rule locked.';}
+ // blocks already on screen take their newest words (pending to included, a shard proven, a checkpoint locked)
+ d.blocks.forEach(function(src){var q=byHash[src.hash];if(q)applyLive(q,src);});
+ fresh.forEach(function(b){liveQ.push({hash:b.hash,parents:b.parents||[],lane:laneFor(b.miner||'?'),src:b});});
+ if(liveQ.length>6)liveQ=liveQ.slice(-6);});
+ setLive(false);
})();
- // 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);});
+ // one fetch on load, then every 2 s while the feed is fresh and every 10 s while it is off, so a feed that comes back is picked up
+ function pollLive(on){setTimeout(function(){pullLive(false).then(pollLive);},on?2000:10000);}
+ pullLive(true).then(pollLive);
// hourly program ticker
var ops=['rotl','rotr','xor','add','sub','mul','mulhi','mad','or'];
diff --git a/site/live.html b/site/live.html
index 00e9c5f44..7ef7e272a 100644
--- a/site/live.html
+++ b/site/live.html
@@ -236,7 +236,7 @@ main{padding-bottom:var(--sec)}
blocks, finality, proving
-
on screen 0chain 0identities 0last lock noneproven n/a
+
on screen 0chain 0identities 0last lock noneproven pending
@@ -402,7 +402,10 @@ main{padding-bottom:var(--sec)}
blocks.forEach(function(b){if(b.ts>vis){on++;if(b.chain){chain++;if(b.shards.length){planned++;if(b.proven)proven++;}}}});
$('c-screen').textContent=on;$('c-chain').textContent=chain;$('c-idents').textContent=idents;
var f=fin;$('c-locked').textContent=f&&f.latest_locked_index?('#'+f.latest_locked_index+(f.checkpoints&&f.checkpoints.length?', '+lockAge(f):'')):(f&&f.supported?'none yet':'n/a');
- $('c-proven').textContent=prov&&prov.supported&&prov.active?(proven+'/'+planned):(prov&&prov.supported?'not active':'n/a');
+ // 6 Oct 2026: proofs land a median of minutes behind the tip, so the on-screen window (proven+'/'+planned) read 0 for ever; the
+ // counter now reads the observer's 10-minute truth: chain blocks with every shard paid, of the chain blocks planned in 10 min
+ var pe=$('c-proven');if(prov&&prov.supported&&prov.active){pe.textContent=(prov.blocks_fully_proven_10m||0)+'/'+(prov.blocks_10m||0)+' in 10 min';pe.title=(prov.shards_paid_10m||0)+' shards paid in the last 10 minutes'+(prov.median_proof_lag_s?', landing a median '+Math.round(prov.median_proof_lag_s)+' s behind the tip':'')+'; '+(prov.paid_shards_total!==null&&prov.paid_shards_total!==undefined?Number(prov.paid_shards_total).toLocaleString('en-GB')+' paid on the chain so far':'');}
+ else{pe.textContent=prov&&prov.supported?'not active':'no proving layer';pe.title='';}
if(!running&&!document.hidden)kick();
}
function lockAge(f){var cps=f.checkpoints||[],lk=null;for(var i=cps.length-1;i>=0;i--)if(cps[i].state==='locked'){lk=cps[i];break;}