Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 640aa34

Browse files
feat: add Graph Consensus Simulator - interactive distributed consensus visualization
New interactive docs/consensus.html with: - 3 consensus algorithms: Simple Majority, Raft Leader Election, PBFT (Byzantine) - 8 graph presets (ring, complete, star, grid, tree, random, bipartite) - Canvas visualization with animated message passing between nodes - Byzantine fault injection (click button or Ctrl+click nodes) - Draggable nodes, speed control, step/run/reset controls - Real-time metrics: rounds, messages, time, healthy/total nodes - Node state table with color-coded states - Message log and autonomous insights after consensus/failure - Proactive recommendations for topology and algorithm selection Usage: Open docs/consensus.html, pick an algorithm and topology, click Run.
1 parent 9f4b8d1 commit 640aa34

1 file changed

Lines changed: 325 additions & 0 deletions

File tree

docs/consensus.html

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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

Comments
 (0)