853 lines
31 KiB
HTML
853 lines
31 KiB
HTML
<!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>
|
|
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>
|