|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | +<title>Graph Consensus Simulator - GraphVisual</title> |
| 7 | +<style> |
| 8 | +*{margin:0;padding:0;box-sizing:border-box} |
| 9 | +body{background:#1a1a2e;color:#e0e0e0;font-family:'Segoe UI',system-ui,sans-serif;overflow:hidden;height:100vh} |
| 10 | +h1{text-align:center;padding:10px;font-size:1.3em;color:#00d4ff;letter-spacing:2px} |
| 11 | +.layout{display:flex;height:calc(100vh - 90px)} |
| 12 | +.left{flex:1;display:flex;flex-direction:column} |
| 13 | +canvas{flex:1;display:block;cursor:crosshair} |
| 14 | +.right{width:320px;background:#16213e;border-left:1px solid #0f3460;overflow-y:auto;padding:12px;font-size:0.85em} |
| 15 | +.controls{display:flex;flex-wrap:wrap;gap:6px;padding:6px 12px;background:#16213e;align-items:center} |
| 16 | +.controls select,.controls button,.controls input{background:#0f3460;color:#e0e0e0;border:1px solid #1a4080;border-radius:4px;padding:4px 8px;font-size:0.82em} |
| 17 | +.controls button{cursor:pointer;transition:background .2s}.controls button:hover{background:#1a5090} |
| 18 | +.controls label{font-size:0.8em;color:#8899aa} |
| 19 | +.bottom{height:120px;background:#16213e;border-top:1px solid #0f3460;padding:8px 12px;overflow-y:auto;font-size:0.82em} |
| 20 | +.bottom h3{color:#00d4ff;font-size:0.9em;margin-bottom:4px} |
| 21 | +.insight{padding:3px 0;border-bottom:1px solid #0f3460} |
| 22 | +.insight::before{content:"💡 ";font-size:0.9em} |
| 23 | +h3{color:#00d4ff;margin:8px 0 4px;font-size:0.95em} |
| 24 | +.log{max-height:150px;overflow-y:auto;font-family:monospace;font-size:0.78em;line-height:1.4;color:#88aacc} |
| 25 | +.log div{border-bottom:1px solid #0f3460;padding:1px 0} |
| 26 | +table{width:100%;border-collapse:collapse;font-size:0.8em} |
| 27 | +th,td{padding:3px 5px;border-bottom:1px solid #0f3460;text-align:left} |
| 28 | +th{color:#00d4ff} |
| 29 | +.metric{display:flex;justify-content:space-between;padding:2px 0} |
| 30 | +.metric span:last-child{color:#00d4ff;font-weight:bold} |
| 31 | +.badge{display:inline-block;padding:1px 6px;border-radius:8px;font-size:0.75em;font-weight:bold} |
| 32 | +.badge-progress{background:#e67e22;color:#fff} |
| 33 | +.badge-done{background:#2ecc71;color:#fff} |
| 34 | +.badge-fail{background:#e74c3c;color:#fff} |
| 35 | +</style> |
| 36 | +</head> |
| 37 | +<body> |
| 38 | +<h1>⚡ Graph Consensus Simulator</h1> |
| 39 | +<div class="controls" id="ctrl"> |
| 40 | + <label>Algorithm: <select id="algo"><option value="majority">Simple Majority</option><option value="raft">Raft Leader Election</option><option value="pbft">PBFT (Byzantine)</option></select></label> |
| 41 | + <label>Preset: <select id="preset"><option value="ring5">Ring-5</option><option value="ring7">Ring-7</option><option value="complete5">Complete-5</option><option value="star5">Star-5</option><option value="grid3">Grid 3×3</option><option value="tree7">Tree-7</option><option value="random8">Random-8</option><option value="bipartite">Bipartite 4+4</option></select></label> |
| 42 | + <label>Speed: <input type="range" id="speed" min="1" max="10" value="5"></label> |
| 43 | + <button id="btnStep">⏭ Step</button> |
| 44 | + <button id="btnRun">▶ Run</button> |
| 45 | + <button id="btnReset">↺ Reset</button> |
| 46 | + <button id="btnFault">💀 Inject Fault</button> |
| 47 | +</div> |
| 48 | +<div class="layout"> |
| 49 | + <div class="left"><canvas id="cv"></canvas></div> |
| 50 | + <div class="right" id="panel"> |
| 51 | + <h3>Status</h3> |
| 52 | + <div id="status">Ready</div> |
| 53 | + <div id="metrics"></div> |
| 54 | + <h3>Node Table</h3> |
| 55 | + <div id="nodeTable"></div> |
| 56 | + <h3>Message Log</h3> |
| 57 | + <div class="log" id="log"></div> |
| 58 | + </div> |
| 59 | +</div> |
| 60 | +<div class="bottom" id="insights"><h3>🤖 Autonomous Insights</h3><div id="insightList">Run a simulation to see insights.</div></div> |
| 61 | + |
| 62 | +<script> |
| 63 | +const cv=document.getElementById('cv'),ctx=cv.getContext('2d'); |
| 64 | +let W,H;function resize(){const r=cv.parentElement.getBoundingClientRect();W=cv.width=r.width;H=cv.height=r.height} |
| 65 | +resize();window.addEventListener('resize',resize); |
| 66 | + |
| 67 | +// --- State --- |
| 68 | +let nodes=[],edges=[],messages=[],logEntries=[],insightEntries=[]; |
| 69 | +let round=0,phase='',running=false,done=false,consensusResult='',msgCount=0,startTime=0; |
| 70 | +let dragNode=null,hoverNode=null,animFrame=0; |
| 71 | + |
| 72 | +// --- Colors --- |
| 73 | +const STATE_COLORS={leader:'#f1c40f',follower:'#3498db',candidate:'#e67e22',byzantine:'#e74c3c',decided:'#2ecc71',idle:'#7f8c8d'}; |
| 74 | + |
| 75 | +// --- Presets --- |
| 76 | +function makeRing(n){const ns=[],es=[];for(let i=0;i<n;i++){const a=2*Math.PI*i/n;ns.push({id:i,x:W/2+120*Math.cos(a),y:H/2+120*Math.sin(a)})}for(let i=0;i<n;i++)es.push([i,(i+1)%n]);return{nodes:ns,edges:es}} |
| 77 | +function makeComplete(n){const ns=[],es=[];for(let i=0;i<n;i++){const a=2*Math.PI*i/n;ns.push({id:i,x:W/2+120*Math.cos(a),y:H/2+120*Math.sin(a)})}for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)es.push([i,j]);return{nodes:ns,edges:es}} |
| 78 | +function makeStar(n){const ns=[{id:0,x:W/2,y:H/2}];for(let i=1;i<n;i++){const a=2*Math.PI*(i-1)/(n-1);ns.push({id:i,x:W/2+140*Math.cos(a),y:H/2+140*Math.sin(a)})}const es=[];for(let i=1;i<n;i++)es.push([0,i]);return{nodes:ns,edges:es}} |
| 79 | +function makeGrid(r,c){const ns=[],es=[];let id=0;const sx=W/2-(c-1)*50,sy=H/2-(r-1)*50;for(let i=0;i<r;i++)for(let j=0;j<c;j++){ns.push({id:id,x:sx+j*100,y:sy+i*100});if(j<c-1)es.push([id,id+1]);if(i<r-1)es.push([id,id+c]);id++}return{nodes:ns,edges:es}} |
| 80 | +function makeTree(n){const ns=[{id:0,x:W/2,y:H/2-100}],es=[];const q=[0];let id=1;while(id<n){const p=q.shift();for(let c=0;c<2&&id<n;c++){const angle=(c===0?-1:1)*0.5;ns.push({id:id,x:ns[p].x+angle*80,y:ns[p].y+80});es.push([p,id]);q.push(id);id++}}return{nodes:ns,edges:es}} |
| 81 | +function makeRandom(n){const ns=[];for(let i=0;i<n;i++)ns.push({id:i,x:W/4+Math.random()*W/2,y:40+Math.random()*(H-80)});const es=[];for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)if(Math.random()<0.4)es.push([i,j]);if(es.length<n-1){for(let i=1;i<n;i++){const j=Math.floor(Math.random()*i);if(!es.some(e=>(e[0]===i&&e[1]===j)||(e[0]===j&&e[1]===i)))es.push([Math.min(i,j),Math.max(i,j)])}}return{nodes:ns,edges:es}} |
| 82 | +function makeBipartite(a,b){const ns=[],es=[];for(let i=0;i<a;i++)ns.push({id:i,x:W/2-100,y:H/2+(i-a/2+0.5)*60});for(let i=0;i<b;i++)ns.push({id:a+i,x:W/2+100,y:H/2+(i-b/2+0.5)*60});for(let i=0;i<a;i++)for(let j=0;j<b;j++)if(Math.random()<0.5)es.push([i,a+j]);if(es.length<a+b-1)for(let i=0;i<a;i++)es.push([i,a+(i%b)]);return{nodes:ns,edges:es}} |
| 83 | + |
| 84 | +const PRESETS={ring5:()=>makeRing(5),ring7:()=>makeRing(7),complete5:()=>makeComplete(5),star5:()=>makeStar(5),grid3:()=>makeGrid(3,3),tree7:()=>makeTree(7),random8:()=>makeRandom(8),bipartite:()=>makeBipartite(4,4)}; |
| 85 | + |
| 86 | +// --- Neighbors --- |
| 87 | +function neighbors(id){const s=new Set();edges.forEach(([a,b])=>{if(a===id)s.add(b);if(b===id)s.add(a)});return[...s]} |
| 88 | + |
| 89 | +// --- Init --- |
| 90 | +function initSim(){ |
| 91 | + const p=PRESETS[document.getElementById('preset').value](); |
| 92 | + nodes=p.nodes.map(n=>({...n,state:'idle',value:null,term:0,votedFor:null,votes:0,prepareCount:0,commitCount:0,decided:false,inbox:[]})); |
| 93 | + edges=p.edges;messages=[];logEntries=[];insightEntries=[];round=0;phase='init';done=false;consensusResult='';msgCount=0;startTime=Date.now(); |
| 94 | + const algo=document.getElementById('algo').value; |
| 95 | + if(algo==='majority')nodes.forEach(n=>{n.value=Math.random()<0.5?0:1;n.state='follower'}); |
| 96 | + else if(algo==='raft')nodes.forEach(n=>{n.state='follower';n.term=0;n.votedFor=null;n.votes=0}); |
| 97 | + else if(algo==='pbft')nodes.forEach(n=>{n.state='follower';n.value=Math.random()<0.5?0:1;n.prepareCount=0;n.commitCount=0;n.decided=false}); |
| 98 | + updatePanel(); |
| 99 | +} |
| 100 | + |
| 101 | +// --- Step logic --- |
| 102 | +function stepMajority(){ |
| 103 | + if(done)return;round++;phase='vote-broadcast'; |
| 104 | + const healthy=nodes.filter(n=>n.state!=='byzantine'); |
| 105 | + // broadcast |
| 106 | + healthy.forEach(s=>{neighbors(s.id).forEach(t=>{if(nodes[t].state!=='byzantine'){sendMsg(s.id,t,'vote',s.value)}})}); |
| 107 | + // after collecting |
| 108 | + setTimeout(()=>{ |
| 109 | + healthy.forEach(n=>{ |
| 110 | + const votes=n.inbox.filter(m=>m.type==='vote'); |
| 111 | + let c0=n.value===0?1:0,c1=n.value===1?1:0; |
| 112 | + votes.forEach(v=>{if(v.data===0)c0++;else c1++}); |
| 113 | + n.value=c0>c1?0:1;n.inbox=[]; |
| 114 | + }); |
| 115 | + phase='count'; |
| 116 | + const vals=healthy.map(n=>n.value); |
| 117 | + if(vals.every(v=>v===vals[0])){done=true;consensusResult='Consensus: value '+vals[0];healthy.forEach(n=>n.state='decided');generateInsights()} |
| 118 | + else if(round>=20){done=true;consensusResult='Failed: no convergence after 20 rounds';generateInsights()} |
| 119 | + updatePanel(); |
| 120 | + },200); |
| 121 | +} |
| 122 | + |
| 123 | +function stepRaft(){ |
| 124 | + if(done)return;round++; |
| 125 | + const healthy=nodes.filter(n=>n.state!=='byzantine'); |
| 126 | + if(nodes.some(n=>n.state==='leader')){ |
| 127 | + // heartbeat |
| 128 | + const leader=nodes.find(n=>n.state==='leader'); |
| 129 | + phase='heartbeat'; |
| 130 | + neighbors(leader.id).forEach(t=>{if(nodes[t].state!=='byzantine')sendMsg(leader.id,t,'heartbeat',leader.term)}); |
| 131 | + healthy.filter(n=>n.id!==leader.id).forEach(n=>{n.state='follower'}); |
| 132 | + done=true;consensusResult='Leader elected: Node '+leader.id+' (term '+leader.term+')'; |
| 133 | + healthy.forEach(n=>{if(n.id!==leader.id)n.state='decided';});leader.state='leader'; |
| 134 | + generateInsights();updatePanel();return; |
| 135 | + } |
| 136 | + // election: pick random candidate |
| 137 | + const candidates=healthy.filter(n=>n.state==='candidate'); |
| 138 | + if(candidates.length===0){ |
| 139 | + const c=healthy[Math.floor(Math.random()*healthy.length)]; |
| 140 | + c.state='candidate';c.term++;c.votes=1;c.votedFor=c.id; |
| 141 | + phase='election (Node '+c.id+' term '+c.term+')'; |
| 142 | + addLog('Node '+c.id+' starts election for term '+c.term); |
| 143 | + neighbors(c.id).forEach(t=>{if(nodes[t].state!=='byzantine')sendMsg(c.id,t,'requestVote',c.term)}); |
| 144 | + } else { |
| 145 | + candidates.forEach(c=>{ |
| 146 | + const voteReqs=c.inbox.filter(m=>m.type==='voteReply'&&m.data===true); |
| 147 | + c.votes=1+voteReqs.length;c.inbox=[]; |
| 148 | + const majority=Math.floor(healthy.length/2)+1; |
| 149 | + if(c.votes>=majority){c.state='leader';addLog('Node '+c.id+' elected leader with '+c.votes+' votes')} |
| 150 | + else if(round>3&&candidates.length>1){c.state='follower';c.votes=0;c.votedFor=null} |
| 151 | + }); |
| 152 | + // others respond |
| 153 | + healthy.filter(n=>n.state==='follower').forEach(n=>{ |
| 154 | + const reqs=n.inbox.filter(m=>m.type==='requestVote'); |
| 155 | + if(reqs.length>0){ |
| 156 | + const req=reqs[0]; |
| 157 | + if(n.votedFor===null||n.votedFor===req.from){ |
| 158 | + n.votedFor=req.from;n.term=req.data; |
| 159 | + sendMsg(n.id,req.from,'voteReply',true); |
| 160 | + addLog('Node '+n.id+' votes for Node '+req.from); |
| 161 | + } else {sendMsg(n.id,req.from,'voteReply',false)} |
| 162 | + } |
| 163 | + n.inbox=[]; |
| 164 | + }); |
| 165 | + } |
| 166 | + if(round>=15&&!done){done=true;consensusResult='Failed: split vote after 15 rounds';generateInsights()} |
| 167 | + updatePanel(); |
| 168 | +} |
| 169 | + |
| 170 | +function stepPBFT(){ |
| 171 | + if(done)return;round++; |
| 172 | + const healthy=nodes.filter(n=>n.state!=='byzantine'); |
| 173 | + const n=nodes.length,f=Math.floor((n-1)/3); |
| 174 | + const primary=nodes[0].state!=='byzantine'?nodes[0]:healthy[0]; |
| 175 | + if(round===1){ |
| 176 | + phase='pre-prepare';primary.state='leader'; |
| 177 | + primary.value=1; |
| 178 | + neighbors(primary.id).forEach(t=>{sendMsg(primary.id,t,'pre-prepare',primary.value)}); |
| 179 | + addLog('Primary Node '+primary.id+' broadcasts pre-prepare (value='+primary.value+')'); |
| 180 | + } else if(round===2){ |
| 181 | + phase='prepare'; |
| 182 | + healthy.forEach(nd=>{ |
| 183 | + const pp=nd.inbox.filter(m=>m.type==='pre-prepare'); |
| 184 | + if(pp.length>0)nd.value=pp[0].data; |
| 185 | + nd.inbox=[]; |
| 186 | + neighbors(nd.id).forEach(t=>{sendMsg(nd.id,t,'prepare',nd.value)}); |
| 187 | + }); |
| 188 | + addLog('Nodes broadcast prepare messages'); |
| 189 | + } else if(round===3){ |
| 190 | + phase='commit'; |
| 191 | + healthy.forEach(nd=>{ |
| 192 | + nd.prepareCount=nd.inbox.filter(m=>m.type==='prepare'&&m.data===nd.value).length; |
| 193 | + nd.inbox=[]; |
| 194 | + if(nd.prepareCount>=2*f){ |
| 195 | + neighbors(nd.id).forEach(t=>{sendMsg(nd.id,t,'commit',nd.value)}); |
| 196 | + } |
| 197 | + }); |
| 198 | + addLog('Nodes with 2f+1 prepares broadcast commit'); |
| 199 | + } else if(round===4){ |
| 200 | + phase='decide'; |
| 201 | + healthy.forEach(nd=>{ |
| 202 | + nd.commitCount=nd.inbox.filter(m=>m.type==='commit'&&m.data===nd.value).length; |
| 203 | + nd.inbox=[]; |
| 204 | + if(nd.commitCount>=2*f){nd.decided=true;nd.state='decided'} |
| 205 | + }); |
| 206 | + const decidedNodes=healthy.filter(nd=>nd.decided); |
| 207 | + if(decidedNodes.length>0){done=true;consensusResult='Consensus: value '+decidedNodes[0].value+' ('+decidedNodes.length+'/'+healthy.length+' decided)';generateInsights()} |
| 208 | + else{done=true;consensusResult='Failed: insufficient commit messages (Byzantine nodes: '+(n-healthy.length)+', tolerance: '+f+')';generateInsights()} |
| 209 | + } |
| 210 | + updatePanel(); |
| 211 | +} |
| 212 | + |
| 213 | +function step(){ |
| 214 | + const algo=document.getElementById('algo').value; |
| 215 | + if(algo==='majority')stepMajority(); |
| 216 | + else if(algo==='raft')stepRaft(); |
| 217 | + else if(algo==='pbft')stepPBFT(); |
| 218 | +} |
| 219 | + |
| 220 | +function sendMsg(from,to,type,data){ |
| 221 | + const s=nodes[from],t=nodes[to]; |
| 222 | + messages.push({from,to,type,data,x:s.x,y:s.y,tx:t.x,ty:t.y,progress:0}); |
| 223 | + t.inbox.push({from,type,data}); |
| 224 | + msgCount++; |
| 225 | +} |
| 226 | + |
| 227 | +function addLog(text){logEntries.unshift('R'+round+': '+text);if(logEntries.length>50)logEntries.pop()} |
| 228 | + |
| 229 | +// --- Insights --- |
| 230 | +function generateInsights(){ |
| 231 | + insightEntries=[]; |
| 232 | + const healthy=nodes.filter(n=>n.state!=='byzantine').length; |
| 233 | + const byz=nodes.length-healthy; |
| 234 | + const algo=document.getElementById('algo').value; |
| 235 | + const elapsed=((Date.now()-startTime)/1000).toFixed(1); |
| 236 | + insightEntries.push('Simulation completed in '+round+' rounds ('+elapsed+'s real time), '+msgCount+' messages exchanged.'); |
| 237 | + if(byz>0)insightEntries.push(byz+' Byzantine node(s) were present. Protocol '+(done&&consensusResult.startsWith('Consensus')?'successfully tolerated':'could not overcome')+' the faults.'); |
| 238 | + const preset=document.getElementById('preset').value; |
| 239 | + if(preset==='star5'&&algo!=='pbft')insightEntries.push('Star topology has a single point of failure at the hub. Try PBFT or a mesh topology for better resilience.'); |
| 240 | + if(algo==='majority')insightEntries.push('Simple Majority has no Byzantine fault tolerance. Try PBFT to see how the network handles malicious nodes.'); |
| 241 | + if(algo==='raft'&&consensusResult.includes('Failed'))insightEntries.push('Raft split votes are common in symmetric topologies. Real Raft uses randomized timeouts to break ties.'); |
| 242 | + if(algo==='pbft')insightEntries.push('PBFT tolerates up to f=⌊(n-1)/3⌋ Byzantine faults. With '+nodes.length+' nodes, f='+Math.floor((nodes.length-1)/3)+'.'); |
| 243 | + if(round<=2&&done&&consensusResult.startsWith('Consensus'))insightEntries.push('Very fast consensus! Dense connectivity helps messages propagate quickly.'); |
| 244 | + if(preset==='ring5'||preset==='ring7')insightEntries.push('Ring topologies have limited connectivity. Information must travel around the ring, increasing round count.'); |
| 245 | +} |
| 246 | + |
| 247 | +// --- Drawing --- |
| 248 | +function draw(){ |
| 249 | + ctx.clearRect(0,0,W,H); |
| 250 | + // edges |
| 251 | + ctx.strokeStyle='#1a4080';ctx.lineWidth=1.5; |
| 252 | + edges.forEach(([a,b])=>{ctx.beginPath();ctx.moveTo(nodes[a].x,nodes[a].y);ctx.lineTo(nodes[b].x,nodes[b].y);ctx.stroke()}); |
| 253 | + // messages |
| 254 | + const spd=0.03*parseInt(document.getElementById('speed').value); |
| 255 | + messages=messages.filter(m=>{ |
| 256 | + m.progress+=spd; |
| 257 | + if(m.progress>=1)return false; |
| 258 | + const x=m.x+(m.tx-m.x)*m.progress,y=m.y+(m.ty-m.y)*m.progress; |
| 259 | + ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2); |
| 260 | + ctx.fillStyle=m.type==='vote'||m.type==='prepare'?'#e67e22':m.type==='commit'?'#2ecc71':m.type==='heartbeat'?'#f1c40f':'#00d4ff'; |
| 261 | + ctx.fill();return true; |
| 262 | + }); |
| 263 | + // nodes |
| 264 | + nodes.forEach(n=>{ |
| 265 | + ctx.beginPath();ctx.arc(n.x,n.y,22,0,Math.PI*2); |
| 266 | + ctx.fillStyle=STATE_COLORS[n.state]||STATE_COLORS.idle;ctx.fill(); |
| 267 | + ctx.strokeStyle=hoverNode===n?'#fff':'#0f3460';ctx.lineWidth=hoverNode===n?3:2;ctx.stroke(); |
| 268 | + ctx.fillStyle='#fff';ctx.font='bold 11px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle'; |
| 269 | + ctx.fillText(''+n.id,n.x,n.y); |
| 270 | + if(n.value!==null&&n.value!==undefined){ctx.font='9px sans-serif';ctx.fillText('v='+n.value,n.x,n.y+14)} |
| 271 | + }); |
| 272 | + // round |
| 273 | + ctx.fillStyle='#8899aa';ctx.font='12px sans-serif';ctx.textAlign='left';ctx.textBaseline='top'; |
| 274 | + ctx.fillText('Round: '+round+' Phase: '+phase,8,8); |
| 275 | + animFrame=requestAnimationFrame(draw); |
| 276 | +} |
| 277 | + |
| 278 | +// --- Panel --- |
| 279 | +function updatePanel(){ |
| 280 | + let s=done?(consensusResult.startsWith('Consensus')?'<span class="badge badge-done">✔ Done</span> ':'<span class="badge badge-fail">✘ Failed</span> '):'<span class="badge badge-progress">● In Progress</span> '; |
| 281 | + s+=consensusResult||'Awaiting start'; |
| 282 | + document.getElementById('status').innerHTML=s; |
| 283 | + let met='';const elapsed=((Date.now()-startTime)/1000).toFixed(1); |
| 284 | + met+='<div class="metric"><span>Rounds</span><span>'+round+'</span></div>'; |
| 285 | + met+='<div class="metric"><span>Messages</span><span>'+msgCount+'</span></div>'; |
| 286 | + met+='<div class="metric"><span>Time</span><span>'+elapsed+'s</span></div>'; |
| 287 | + met+='<div class="metric"><span>Healthy/Total</span><span>'+nodes.filter(n=>n.state!=='byzantine').length+'/'+nodes.length+'</span></div>'; |
| 288 | + document.getElementById('metrics').innerHTML=met; |
| 289 | + let t='<table><tr><th>ID</th><th>State</th><th>Value</th><th>Term</th></tr>'; |
| 290 | + nodes.forEach(n=>{t+='<tr><td>'+n.id+'</td><td style="color:'+STATE_COLORS[n.state]+'">'+n.state+'</td><td>'+(n.value??'-')+'</td><td>'+n.term+'</td></tr>'}); |
| 291 | + t+='</table>';document.getElementById('nodeTable').innerHTML=t; |
| 292 | + document.getElementById('log').innerHTML=logEntries.map(l=>'<div>'+l+'</div>').join(''); |
| 293 | + document.getElementById('insightList').innerHTML=insightEntries.length?insightEntries.map(i=>'<div class="insight">'+i+'</div>').join(''):'Run a simulation to see insights.'; |
| 294 | +} |
| 295 | + |
| 296 | +// --- Controls --- |
| 297 | +document.getElementById('btnStep').onclick=()=>{if(!done)step()}; |
| 298 | +let runInterval=null; |
| 299 | +document.getElementById('btnRun').onclick=function(){ |
| 300 | + if(running){running=false;this.textContent='▶ Run';clearInterval(runInterval);return} |
| 301 | + running=true;this.textContent='⏸ Pause'; |
| 302 | + const ms=()=>Math.max(100,1100-parseInt(document.getElementById('speed').value)*100); |
| 303 | + runInterval=setInterval(()=>{if(done){running=false;document.getElementById('btnRun').textContent='▶ Run';clearInterval(runInterval);return}step()},ms()); |
| 304 | +}; |
| 305 | +document.getElementById('btnReset').onclick=()=>{running=false;document.getElementById('btnRun').textContent='▶ Run';clearInterval(runInterval);initSim()}; |
| 306 | +document.getElementById('btnFault').onclick=()=>{const h=nodes.filter(n=>n.state!=='byzantine');if(h.length>1){const n=h[Math.floor(Math.random()*h.length)];n.state='byzantine';addLog('💀 Node '+n.id+' became Byzantine!');updatePanel()}}; |
| 307 | +document.getElementById('preset').onchange=()=>{running=false;clearInterval(runInterval);document.getElementById('btnRun').textContent='▶ Run';initSim()}; |
| 308 | +document.getElementById('algo').onchange=()=>{running=false;clearInterval(runInterval);document.getElementById('btnRun').textContent='▶ Run';initSim()}; |
| 309 | + |
| 310 | +// --- Mouse --- |
| 311 | +cv.addEventListener('mousedown',e=>{const r=cv.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top; |
| 312 | + const n=nodes.find(n=>Math.hypot(n.x-mx,n.y-my)<24); |
| 313 | + if(n){if(e.shiftKey||e.ctrlKey){n.state=n.state==='byzantine'?'follower':'byzantine';addLog((n.state==='byzantine'?'💀':'❤️')+' Node '+n.id+' toggled');updatePanel()}else dragNode=n} |
| 314 | +}); |
| 315 | +cv.addEventListener('mousemove',e=>{const r=cv.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top; |
| 316 | + if(dragNode){dragNode.x=mx;dragNode.y=my} |
| 317 | + hoverNode=nodes.find(n=>Math.hypot(n.x-mx,n.y-my)<24)||null; |
| 318 | +}); |
| 319 | +cv.addEventListener('mouseup',()=>{dragNode=null}); |
| 320 | + |
| 321 | +// --- Boot --- |
| 322 | +initSim();draw(); |
| 323 | +</script> |
| 324 | +</body> |
| 325 | +</html> |
0 commit comments