blockchain sandbox: UTXO chain, p2p mining, attacker node

This commit is contained in:
2026-07-26 20:37:26 +03:00
commit 7ce39c8964
18 changed files with 2536 additions and 0 deletions
+852
View File
@@ -0,0 +1,852 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blockchain Explorer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0a0a0f; color: #e0e0e0; }
.layout { display: grid; grid-template-columns: 280px 1fr; height: 100vh; }
.sidebar {
background: #0e0e16; border-right: 1px solid #1a1a2a; padding: 16px;
overflow-y: auto; display: flex; flex-direction: column;
}
.sidebar h2 { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 10px; }
.sidebar .stats { font-size: 11px; color: #444; margin-bottom: 12px; }
.sidebar .stats span { color: #00ff88; }
/* network health panel */
.net-health {
margin-bottom: 12px; padding: 8px; border-radius: 6px;
background: #0c0c14; border: 1px solid #1a1a2a;
}
.net-health .nh-title {
font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 6px;
}
.nh-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; margin-bottom: 4px; }
.nh-row .nh-label { color: #666; }
.nh-row .nh-val { font-weight: bold; }
.nh-row .nh-val.good { color: #00ff88; }
.nh-row .nh-val.warn { color: #ffe66d; }
.nh-row .nh-val.bad { color: #ff4444; }
.consensus-bar {
height: 8px; border-radius: 4px; background: #1a1a2a; overflow: hidden;
display: flex; margin-top: 4px;
}
.consensus-bar .cb-seg {
height: 100%; transition: width 0.5s ease;
}
.nodes-list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; flex: 1; }
.chain-group {
background: #0c0c14; border-radius: 5px;
padding: 4px; display: flex; flex-direction: column; gap: 2px;
border: 1px solid #1a1a2a;
transition: border-color 0.3s;
}
.chain-group.fork-majority { border-color: #00ff8844; }
.chain-group.fork-minority { border-color: #ff444444; }
.chain-group.fork-stale { border-color: #ff444422; opacity: 0.6; }
.chain-group-header {
display: flex; justify-content: space-between; align-items: center;
font-size: 9px; color: #444; padding: 2px 6px;
}
.chain-group-header .cg-hash { color: #333; }
.chain-group-header .cg-len { color: #00ff88; }
.cg-tag {
font-size: 8px; padding: 1px 5px; border-radius: 3px; font-weight: bold;
text-transform: uppercase; letter-spacing: 0.5px;
}
.cg-tag.majority { background: #00ff8822; color: #00ff88; }
.cg-tag.fork { background: #ff444422; color: #ff4444; }
.cg-tag.stale { background: #ff444411; color: #ff4444; opacity: 0.6; }
.node-row {
display: flex; justify-content: space-between; align-items: center;
padding: 3px 6px; border-radius: 3px; font-size: 11px;
background: #12121a; transition: background 0.3s;
border-left: 3px solid transparent;
}
.node-row.highlight { background: #1a2a1a; }
.node-row .n-left { display: flex; align-items: center; gap: 5px; }
.node-row .n-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.node-row .n-addr { font-size: 10px; }
.node-row .n-power { font-size: 9px; color: #555; }
.node-row .n-bal { color: #ffe66d; font-weight: bold; min-width: 40px; text-align: right; }
.node-row .n-bal.zero { color: #333; font-weight: normal; }
.main { padding: 16px; overflow: hidden; display: flex; flex-direction: column; }
.chain-strip {
height: 110px; min-height: 110px; max-height: 110px;
overflow-x: auto; overflow-y: hidden;
padding: 8px 10px; border-bottom: 1px solid #1a1a2a;
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
flex-wrap: nowrap;
}
.chain-strip .block-card {
width: 100px !important; height: 88px !important; min-width: 100px;
padding: 6px 8px !important; animation: none !important;
}
.chain-strip .block-top .b-idx { font-size: 13px; }
.chain-strip .block-top .b-hash { font-size: 7px; }
.chain-strip .block-txs .mini-tx { font-size: 8px; gap: 2px; }
.chain-strip .block-txs .mini-tx .mt-amt { font-size: 8px; }
.chain-strip .chain-link { height: 88px; font-size: 12px; }
.chain-strip .more-indicator { height: 88px; font-size: 9px; }
.chain-strip .genesis-label { font-size: 8px; }
.chain-strip .block-detail { display: none !important; }
.graph-wrap { flex: 1; position: relative; }
.graph-wrap canvas { width: 100%; height: 100%; display: block; }
.layers {
position: absolute; top: 8px; right: 8px; z-index: 5;
background: #0e0e16dd; border: 1px solid #1a1a2a; border-radius: 6px;
padding: 8px 10px; display: flex; flex-direction: column; gap: 4px;
backdrop-filter: blur(8px);
}
.layers .l-title {
font-size: 8px; color: #555; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 2px;
}
.layer-btn {
display: flex; align-items: center; gap: 6px; cursor: pointer;
font-size: 10px; color: #666; padding: 2px 0; user-select: none;
transition: color 0.2s;
}
.layer-btn:hover { color: #aaa; }
.layer-btn.on { color: #e0e0e0; }
.layer-btn .l-dot {
width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid #444;
transition: background 0.2s, border-color 0.2s;
}
.layer-btn.on .l-dot { border-color: currentColor; }
.chain {
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 6px;
padding: 0;
}
.chain-link { color: #2a2a3a; font-size: 16px; display: flex; align-items: center; height: 160px; flex-shrink: 0; }
/* block card - square */
.block-card {
width: 160px; height: 160px; flex-shrink: 0;
background: #111118; border-radius: 10px; border: 1px solid #222;
display: flex; flex-direction: column;
padding: 10px; cursor: pointer;
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
position: relative;
animation: blockPop 0.4s ease-out;
overflow: hidden;
}
.block-card:hover { background: #181822; transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.4); }
@keyframes blockPop {
0% { opacity: 0; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}
.block-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.block-top .b-idx { font-weight: bold; font-size: 16px; }
.block-top .b-hash { color: #2a2a3a; font-size: 8px; }
/* transaction list inside block */
.block-txs {
display: flex; flex-direction: column; gap: 2px;
flex: 1; overflow: hidden;
}
.mini-tx {
display: flex; align-items: center; gap: 3px;
font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.mini-tx .mt-arrow { color: #333; }
.mini-tx .mt-amt { color: #ffe66d; margin-left: auto; display: flex; align-items: center; gap: 2px; }
.mini-tx.coinbase { color: #00ff88; }
.mini-tx.coinbase .mt-amt { color: #f7931a; }
.coin { color: #f7931a; margin-left: 1px; }
.genesis-label { color: #333; font-size: 10px; margin: auto; }
/* detail popup */
.block-detail {
display: none; position: absolute; top: calc(100% + 6px); left: 0;
z-index: 10; width: 300px; padding: 10px;
background: #16161e; border: 1px solid #2a2a3a; border-radius: 8px;
font-size: 10px; color: #888;
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
}
.block-card.open .block-detail { display: block; }
.block-detail .d-row { padding: 2px 0; word-break: break-all; }
.block-detail .d-label { color: #555; }
.block-detail .d-sep { border-top: 1px solid #222; margin: 4px 0; }
.block-detail .d-tx { padding: 3px 0; display: flex; align-items: center; gap: 4px; font-size: 10px; }
.block-detail .d-tx .dt-arrow { color: #444; }
.block-detail .d-tx .dt-amt { color: #ffe66d; font-weight: bold; margin-left: auto; }
.more-indicator { color: #333; font-size: 11px; flex-shrink: 0; padding: 0 8px; display: flex; align-items: center; height: 160px; }
::-webkit-scrollbar { width: 4px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #222; border-radius: 2px; }
@media (max-width: 700px) {
.layout { grid-template-columns: 1fr; grid-template-rows: 180px 1fr; }
}
</style>
</head>
<body>
<div class="layout">
<div class="sidebar">
<h2>Nodes</h2>
<div class="stats">
Total: <span id="node-count">0</span> &nbsp;
Supply: <span id="total-supply">0</span>
</div>
<div class="net-health" id="net-health">
<div class="nh-title">Network Health</div>
<div class="nh-row">
<span class="nh-label">Consensus</span>
<span class="nh-val good" id="nh-consensus">100%</span>
</div>
<div class="nh-row">
<span class="nh-label">Forks</span>
<span class="nh-val" id="nh-forks">1</span>
</div>
<div class="nh-row">
<span class="nh-label">Max height</span>
<span class="nh-val" id="nh-height">0</span>
</div>
<div class="consensus-bar" id="consensus-bar"></div>
</div>
<div class="nodes-list" id="nodes-list"></div>
</div>
<div class="main">
<div class="chain-strip" id="chain"></div>
<div class="graph-wrap">
<canvas id="graph-canvas"></canvas>
<div class="layers">
<div class="l-title">Layers</div>
<div class="layer-btn on" data-layer="propagation"><span class="l-dot" style="background:#4488ff"></span>Block propagation</div>
<div class="layer-btn" data-layer="txs"><span class="l-dot" style="background:#ffe66d"></span>Transactions</div>
<div class="layer-btn" data-layer="mempool"><span class="l-dot" style="background:#f7931a"></span>Mempool</div>
<div class="layer-btn" data-layer="heights"><span class="l-dot" style="background:#ffe66d"></span>Chain height</div>
<div class="layer-btn" data-layer="forks"><span class="l-dot" style="background:#ff4444"></span>Fork groups</div>
<div class="layer-btn" data-layer="sync"><span class="l-dot" style="background:#cc44ff"></span>Sync beams</div>
<div class="layer-btn" data-layer="reorg"><span class="l-dot" style="background:#ff8844"></span>Reorgs</div>
</div>
</div>
</div>
</div>
<script>
const API = '';
let lastBlockCount = 0;
let prevNodeBalances = {};
let selectedNode = 0;
const NODE_COLORS = [
'#ff6b6b', '#4ecdc4', '#ffe66d', '#a78bfa',
'#f97316', '#06b6d4', '#ec4899', '#84cc16',
'#e879f9', '#2dd4bf', '#fb923c', '#67e8f9',
'#f472b6', '#bef264', '#c084fc', '#5eead4',
'#fca5a5', '#a5f3fc', '#fde047', '#d8b4fe'
];
const nodeColorMap = {};
let colorIdx = 0;
function nodeColor(addr) {
if (!addr) return '#555';
if (!nodeColorMap[addr]) {
nodeColorMap[addr] = NODE_COLORS[colorIdx % NODE_COLORS.length];
colorIdx++;
}
return nodeColorMap[addr];
}
function short(s) {
if (!s) return '';
return s.slice(0, 7);
}
function parseTx(txStr) {
const m = txStr.match(/^(.+) -> (.+): (\d+)$/);
if (!m) return null;
return { from: m[1], to: m[2], amount: m[3] };
}
function powerLabel(us) {
if (us === 0) return 'max';
return us + '\u00B5s';
}
document.addEventListener('click', (e) => {
const card = e.target.closest('.block-card');
document.querySelectorAll('.block-card.open').forEach(el => {
if (el !== card) el.classList.remove('open');
});
if (card) card.classList.toggle('open');
});
async function fetchNodes() {
try {
const resp = await fetch(API + '/nodes');
const nodes = await resp.json();
const list = document.getElementById('nodes-list');
// group nodes by chain they see (last_hash)
const groups = {};
let totalSupply = 0;
for (const n of nodes) {
totalSupply += n.balance;
const key = n.last_hash;
if (!groups[key]) groups[key] = { len: n.chain_len, hash: n.last_hash, nodes: [] };
groups[key].nodes.push(n);
}
// sort groups: longest chain first, then by number of nodes
const sorted = Object.values(groups).sort((a, b) => b.len - a.len || b.nodes.length - a.nodes.length);
const totalNodes = nodes.length;
const maxLen = sorted.length > 0 ? sorted[0].len : 0;
const majorityGroup = sorted[0];
const majorityPct = totalNodes > 0 ? Math.round((majorityGroup.nodes.length / totalNodes) * 100) : 100;
const forkCount = sorted.length;
// update health panel
const nhConsensus = document.getElementById('nh-consensus');
nhConsensus.textContent = majorityPct + '%';
nhConsensus.className = 'nh-val ' + (majorityPct >= 80 ? 'good' : majorityPct >= 50 ? 'warn' : 'bad');
const nhForks = document.getElementById('nh-forks');
nhForks.textContent = forkCount;
nhForks.className = 'nh-val ' + (forkCount <= 1 ? 'good' : forkCount <= 3 ? 'warn' : 'bad');
document.getElementById('nh-height').textContent = '#' + (maxLen - 1);
// consensus bar — each segment is a fork
const FORK_COLORS = ['#00ff88', '#ff4444', '#ff8844', '#ffcc44', '#cc44ff', '#4488ff', '#44ffcc', '#ff44aa'];
let barHtml = '';
for (let i = 0; i < sorted.length; i++) {
const pct = (sorted[i].nodes.length / totalNodes) * 100;
const col = i === 0 ? FORK_COLORS[0] : FORK_COLORS[(i % (FORK_COLORS.length - 1)) + 1];
barHtml += `<div class="cb-seg" style="width:${pct}%;background:${col}"></div>`;
}
document.getElementById('consensus-bar').innerHTML = barHtml;
// render node groups
let html = '';
for (let gi = 0; gi < sorted.length; gi++) {
const g = sorted[gi];
const isMajority = gi === 0 && sorted.length > 1;
const isStale = g.len < maxLen - 2;
const forkClass = sorted.length <= 1 ? '' : isMajority ? 'fork-majority' : isStale ? 'fork-stale' : 'fork-minority';
const tagClass = sorted.length <= 1 ? '' : isMajority ? 'majority' : isStale ? 'stale' : 'fork';
const tagLabel = sorted.length <= 1 ? '' : isMajority ? 'majority' : isStale ? 'stale fork' : 'fork';
const forkColor = gi === 0 ? FORK_COLORS[0] : FORK_COLORS[(gi % (FORK_COLORS.length - 1)) + 1];
html += `<div class="chain-group ${forkClass}">
<div class="chain-group-header">
<span class="cg-len">#${g.len - 1} \u2022 ${g.nodes.length} nodes</span>
${tagLabel ? `<span class="cg-tag ${tagClass}">${tagLabel}</span>` : ''}
<span class="cg-hash">${short(g.hash)}</span>
</div>`;
for (const n of g.nodes) {
const balClass = n.balance === 0 ? 'n-bal zero' : 'n-bal';
const addr = short(n.address);
const color = nodeColor(n.address);
const prevBal = prevNodeBalances[addr];
const changed = prevBal !== undefined && prevBal !== n.balance;
html += `<div class="node-row ${changed ? 'highlight' : ''}" style="border-left-color:${color}">
<span class="n-left">
<span class="n-dot" style="background:${color}"></span>
<span class="n-addr" style="color:${color}">${addr}</span>
<span class="n-power">${powerLabel(n.hash_delay_us)}</span>
</span>
<span class="${balClass}">${n.balance} <span class="coin">\u20BF</span></span>
</div>`;
prevNodeBalances[addr] = n.balance;
}
html += '</div>';
}
list.innerHTML = html;
document.getElementById('node-count').textContent = nodes.length;
document.getElementById('total-supply').innerHTML = totalSupply + ' <span class="coin">\u20BF</span>';
} catch(e) {}
}
async function fetchChain() {
try {
const resp = await fetch(API + '/chain');
const blocks = await resp.json();
const container = document.getElementById('chain');
if (blocks.length === lastBlockCount) return;
container.innerHTML = '';
const maxShow = 60;
const start = Math.max(0, blocks.length - maxShow);
if (start > 0) {
container.innerHTML += `<span class="more-indicator">\u2026${start}</span>`;
container.innerHTML += `<span class="chain-link">\u2500\u2500</span>`;
}
for (let i = start; i < blocks.length; i++) {
const b = blocks[i];
const mc = nodeColor(b.miner);
let miniTxs = '', detailTxs = '', txCount = 0;
for (const txStr of b.txs) {
const tx = parseTx(txStr);
if (!tx) continue;
txCount++;
const isCB = tx.from === 'COINBASE';
const fc = nodeColor(tx.from), tc = nodeColor(tx.to);
if (txCount <= 4) {
miniTxs += `<div class="mini-tx ${isCB ? 'coinbase' : ''}">
<span style="color:${isCB ? '#00ff88' : fc}">${isCB ? '\u26CF' : tx.from}</span>
<span class="mt-arrow">\u2192</span>
<span style="color:${tc}">${tx.to}</span>
<span class="mt-amt">${tx.amount}<span class="coin">\u20BF</span></span>
</div>`;
}
detailTxs += `<div class="d-tx">
<span style="color:${isCB ? '#00ff88' : fc}">${isCB ? 'COINBASE' : tx.from}</span>
<span class="dt-arrow">\u2192</span>
<span style="color:${tc}">${tx.to}</span>
<span class="dt-amt">${tx.amount}<span class="coin">\u20BF</span></span>
</div>`;
}
if (txCount > 4) {
miniTxs += `<div class="mini-tx" style="color:#444">+${txCount - 4} more</div>`;
}
const isGenesis = !b.miner;
container.innerHTML += `
<div class="block-card" style="border-color:${isGenesis ? '#222' : mc}22; border-top: 3px solid ${isGenesis ? '#333' : mc}">
<div class="block-top">
<span class="b-idx" style="color:${mc}">#${b.index}</span>
<span class="b-hash">${b.hash.slice(0, 8)}</span>
</div>
${isGenesis ? '<span class="genesis-label">genesis</span>' : `<div style="flex:1;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:bold;color:${mc}88">${txCount}<span style="font-size:10px;color:#555;margin-left:3px">tx</span></div>`}
<div class="block-detail">
<div class="d-row"><span class="d-label">Hash </span>${b.hash}</div>
<div class="d-row"><span class="d-label">Prev </span>${b.prev_hash || 'none'}</div>
${b.miner ? `<div class="d-row"><span class="d-label">Miner </span><span style="color:${mc}">${b.miner}</span></div>` : ''}
${detailTxs ? `<div class="d-sep"></div>${detailTxs}` : ''}
</div>
</div>`;
if (i < blocks.length - 1) {
container.innerHTML += `<span class="chain-link">\u2500\u25B6</span>`;
}
}
lastBlockCount = blocks.length;
container.scrollLeft = container.scrollWidth;
} catch(e) {}
}
// graph auto-init
// --- layers ---
const layers = { propagation: true, txs: false, mempool: false, heights: false, forks: false, sync: false, reorg: false };
document.querySelectorAll('.layer-btn').forEach(btn => {
btn.addEventListener('click', () => {
const layer = btn.dataset.layer;
layers[layer] = !layers[layer];
btn.classList.toggle('on', layers[layer]);
});
});
// --- network graph ---
let graphInited = false;
let topoNodes = [];
let topoEdges = [];
let idMap = {};
const nodeRadius = 10;
const HOP_DELAY = 350; // block ~2.2s, diameter ~3 hops → wave finishes in ~1s
// layer data
let syncBeams = []; // {fromIdx, toIdx, time}
let reorgFlashes = []; // {idx, time, depth}
let txBeams = []; // {fromIdx, toIdx, time}
let nodeHeights = {}; // id → chain_len
let nodeTips = {}; // id → last_hash (for fork coloring)
let nodePending = {}; // id → pending tx count
async function initGraph() {
if (graphInited) return;
graphInited = true;
const resp = await fetch(API + '/topology');
const topo = await resp.json();
topo.forEach((n, i) => { idMap[n.id] = i; });
const W = 800, H = 600;
topoNodes = topo.map((n, i) => ({
id: n.id,
x: W/2 + (Math.random() - 0.5) * W * 0.8,
y: H/2 + (Math.random() - 0.5) * H * 0.8,
vx: 0, vy: 0,
peers: n.peers,
peerIdx: n.peers.map(p => idMap[p]).filter(p => p !== undefined),
color: nodeColor(n.id),
lit: 0, litColor: '', litTime: 0, isMiner: false, txFlash: 0
}));
const edgeSet = new Set();
for (const n of topo) {
const i = idMap[n.id];
for (const pid of n.peers) {
const j = idMap[pid];
if (j === undefined) continue;
const key = Math.min(i,j) + '-' + Math.max(i,j);
if (!edgeSet.has(key)) { edgeSet.add(key); topoEdges.push({a: i, b: j}); }
}
}
// periodic fetch of heights & tips for height/fork layers
async function fetchHeights() {
try {
const r = await fetch(API + '/nodes');
const nodes = await r.json();
for (const n of nodes) {
const sid = short(n.address);
nodeHeights[sid] = n.chain_len - 1;
nodeTips[sid] = n.last_hash;
nodePending[sid] = n.pending_txs || 0;
}
} catch(e) {}
}
fetchHeights();
setInterval(fetchHeights, 1500);
// SSE events
let currentBlockHash = '';
const blockHops = {};
const sse = new EventSource(API + '/events');
sse.onmessage = (e) => {
const ev = JSON.parse(e.data);
const ni = idMap[ev.node];
if (ni === undefined) return;
const hash = ev.block_hash;
if (ev.type === 'block_mine') {
currentBlockHash = hash;
blockHops[hash] = { [ev.node]: 0 };
for (const nd of topoNodes) { nd.lit = 0; nd.isMiner = false; }
topoNodes[ni].lit = 1;
topoNodes[ni].litColor = '#00ff88';
topoNodes[ni].litTime = performance.now();
topoNodes[ni].isMiner = true;
} else if (ev.type === 'block_accept') {
if (!blockHops[hash]) blockHops[hash] = {};
const senderHop = (ev.from && blockHops[hash][ev.from] !== undefined) ? blockHops[hash][ev.from] : 0;
const myHop = senderHop + 1;
blockHops[hash][ev.node] = myHop;
const capturedHash = hash;
setTimeout(() => {
if (capturedHash !== currentBlockHash) return; // stale block, skip
if (topoNodes[ni].lit > 0) return;
topoNodes[ni].lit = 1;
topoNodes[ni].litColor = '#4488ff';
topoNodes[ni].litTime = performance.now();
}, myHop * HOP_DELAY);
} else if (ev.type === 'sync_request') {
const fi = idMap[ev.from];
if (fi !== undefined) {
syncBeams.push({fromIdx: ni, toIdx: fi, time: performance.now()});
}
} else if (ev.type === 'reorg') {
reorgFlashes.push({idx: ni, time: performance.now(), depth: ev.tx_count});
} else if (ev.type === 'tx_send') {
// ev.node = sender, ev.block_hash = receiver shortID, ev.tx_count = amount
const ri = idMap[ev.block_hash];
topoNodes[ni].txFlash = performance.now();
if (ri !== undefined) {
txBeams.push({fromIdx: ni, toIdx: ri, time: performance.now(), amount: ev.tx_count});
}
}
};
setInterval(() => {
const keys = Object.keys(blockHops);
if (keys.length > 10) { for (const k of keys.slice(0, keys.length - 10)) delete blockHops[k]; }
}, 10000);
drawGraph();
}
function drawGraph() {
const canvas = document.getElementById('graph-canvas');
const wrap = canvas.parentElement;
const dpr = window.devicePixelRatio || 1;
canvas.width = wrap.clientWidth * dpr;
canvas.height = wrap.clientHeight * dpr;
canvas.style.width = wrap.clientWidth + 'px';
canvas.style.height = wrap.clientHeight + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const W = wrap.clientWidth, H = wrap.clientHeight;
const now = performance.now();
// --- force simulation ---
const kSpring = 0.004, rep = 10000, damp = 0.85, idealLen = 160;
for (let i = 0; i < topoNodes.length; i++) {
for (let j = i+1; j < topoNodes.length; j++) {
let dx = topoNodes[j].x - topoNodes[i].x;
let dy = topoNodes[j].y - topoNodes[i].y;
let d = Math.sqrt(dx*dx + dy*dy) || 1;
let f = rep / (d * d);
topoNodes[i].vx -= dx/d * f; topoNodes[i].vy -= dy/d * f;
topoNodes[j].vx += dx/d * f; topoNodes[j].vy += dy/d * f;
}
}
for (const e of topoEdges) {
const a = topoNodes[e.a], b = topoNodes[e.b];
let dx = b.x - a.x, dy = b.y - a.y;
let d = Math.sqrt(dx*dx + dy*dy) || 1;
let f = kSpring * (d - idealLen);
a.vx += dx/d * f; a.vy += dy/d * f;
b.vx -= dx/d * f; b.vy -= dy/d * f;
}
for (const n of topoNodes) {
n.vx += (W/2 - n.x) * 0.001;
n.vy += (H/2 - n.y) * 0.001;
n.vx *= damp; n.vy *= damp;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(20, Math.min(W - 20, n.x));
n.y = Math.max(20, Math.min(H - 20, n.y));
}
// --- fork group colors ---
const FORK_PALETTE = ['#4488ff', '#ff4444', '#ff8844', '#ffcc44', '#cc44ff', '#44ffcc'];
const tipToColor = {};
let tipIdx = 0;
if (layers.forks) {
const tipCounts = {};
for (const id in nodeTips) { tipCounts[nodeTips[id]] = (tipCounts[nodeTips[id]] || 0) + 1; }
const sortedTips = Object.entries(tipCounts).sort((a,b) => b[1] - a[1]);
for (const [tip] of sortedTips) {
tipToColor[tip] = FORK_PALETTE[tipIdx % FORK_PALETTE.length];
tipIdx++;
}
}
// --- draw ---
ctx.clearRect(0, 0, W, H);
// edges
for (const e of topoEdges) {
const a = topoNodes[e.a], b = topoNodes[e.b];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
if (layers.propagation && a.lit > 0 && b.lit > 0) {
ctx.lineWidth = 2; ctx.strokeStyle = '#00ff8844';
} else if (layers.forks && nodeTips[a.id] && nodeTips[b.id] && nodeTips[a.id] !== nodeTips[b.id]) {
ctx.lineWidth = 1.5; ctx.strokeStyle = '#ff444444';
} else {
ctx.lineWidth = 0.5; ctx.strokeStyle = '#1a1a2a';
}
ctx.stroke();
}
// tx beams — yellow traveling dots from sender to receiver
if (layers.txs) {
for (let i = txBeams.length - 1; i >= 0; i--) {
const tb = txBeams[i];
const age = now - tb.time;
if (age > 600) { txBeams.splice(i, 1); continue; }
const t = age / 600;
const a = topoNodes[tb.fromIdx], b = topoNodes[tb.toIdx];
const px = a.x + (b.x - a.x) * t;
const py = a.y + (b.y - a.y) * t;
const alpha = 1 - t * 0.5;
// line
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(px, py);
ctx.strokeStyle = '#ffe66d' + Math.round(alpha * 60).toString(16).padStart(2, '0');
ctx.lineWidth = 1;
ctx.stroke();
// dot
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fillStyle = '#ffe66d' + Math.round(alpha * 220).toString(16).padStart(2, '0');
ctx.fill();
}
}
// sync beams
if (layers.sync) {
for (let i = syncBeams.length - 1; i >= 0; i--) {
const s = syncBeams[i];
const age = now - s.time;
if (age > 3000) { syncBeams.splice(i, 1); continue; }
const alpha = 1 - age / 3000;
const a = topoNodes[s.fromIdx], b = topoNodes[s.toIdx];
ctx.beginPath();
ctx.setLineDash([6, 4]);
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = '#cc44ff' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
// arrow head
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const mx = a.x + (b.x - a.x) * 0.6, my = a.y + (b.y - a.y) * 0.6;
ctx.beginPath();
ctx.moveTo(mx, my);
ctx.lineTo(mx - 8*Math.cos(angle-0.4), my - 8*Math.sin(angle-0.4));
ctx.lineTo(mx - 8*Math.cos(angle+0.4), my - 8*Math.sin(angle+0.4));
ctx.closePath();
ctx.fillStyle = '#cc44ff' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.fill();
}
}
// nodes
for (const n of topoNodes) {
const age = now - n.litTime;
const isLit = n.lit > 0;
const tip = nodeTips[n.id];
const forkColor = layers.forks && tip ? (tipToColor[tip] || '#555') : null;
// propagation ring
if (layers.propagation && isLit && age < 800) {
const ringProgress = age / 800;
const ringR = nodeRadius + 30 * ringProgress;
ctx.beginPath();
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = n.litColor + Math.round((1 - ringProgress) * 180).toString(16).padStart(2, '0');
ctx.lineWidth = 3 * (1 - ringProgress);
ctx.stroke();
}
// glow
if (layers.propagation && isLit) {
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 8, 0, Math.PI * 2);
ctx.fillStyle = n.litColor + '30';
ctx.fill();
}
// node circle
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius, 0, Math.PI * 2);
if (layers.forks && forkColor) {
ctx.fillStyle = forkColor;
} else if (layers.propagation && isLit) {
ctx.fillStyle = n.litColor;
} else {
ctx.fillStyle = '#333';
}
ctx.fill();
ctx.strokeStyle = '#555'; ctx.lineWidth = 0.5; ctx.stroke();
// miner ring
if (n.isMiner && layers.propagation) {
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 3, 0, Math.PI * 2);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
}
// mempool ring
if (layers.mempool) {
const pending = nodePending[n.id] || 0;
if (pending > 0) {
const thickness = Math.min(pending * 1.5, 12);
const alpha = Math.min(0.3 + pending * 0.05, 0.8);
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 3 + thickness / 2, 0, Math.PI * 2);
ctx.strokeStyle = '#f7931a' + Math.round(alpha * 255).toString(16).padStart(2, '0');
ctx.lineWidth = thickness;
ctx.stroke();
// count label
ctx.fillStyle = '#f7931a';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(pending, n.x, n.y - nodeRadius - 4);
}
}
// tx flash — small yellow ping
if (layers.txs && n.txFlash > 0) {
const txAge = now - n.txFlash;
if (txAge < 400) {
const tp = txAge / 400;
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 10 * tp, 0, Math.PI * 2);
ctx.strokeStyle = '#ffe66d' + Math.round((1 - tp) * 150).toString(16).padStart(2, '0');
ctx.lineWidth = 2 * (1 - tp);
ctx.stroke();
}
}
// height label inside node
if (layers.heights && nodeHeights[n.id] !== undefined) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(nodeHeights[n.id], n.x, n.y);
}
// node ID below
ctx.fillStyle = '#ffffff33';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(n.id, n.x, n.y + nodeRadius + 4);
}
// reorg flashes
if (layers.reorg) {
for (let i = reorgFlashes.length - 1; i >= 0; i--) {
const r = reorgFlashes[i];
const age = now - r.time;
if (age > 2000) { reorgFlashes.splice(i, 1); continue; }
const n = topoNodes[r.idx];
const alpha = 1 - age / 2000;
// pulsing red ring
const ringR = nodeRadius + 20 + 15 * Math.sin(age / 100);
ctx.beginPath();
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = '#ff4444' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.lineWidth = 3;
ctx.stroke();
// label
ctx.fillStyle = '#ff8844' + Math.round(alpha * 255).toString(16).padStart(2, '0');
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText('REORG +' + r.depth, n.x, n.y - nodeRadius - 14);
}
}
requestAnimationFrame(drawGraph);
}
fetchNodes();
fetchChain();
initGraph();
setInterval(fetchChain, 1000);
setInterval(fetchNodes, 1000);
</script>
</body>
</html>