-
Event Log
-
- |◀
- ◀
- ▶
- ▶
- ▶|
-
- 0/0
-
-
- ms/event
-
-
-
-
- Seq Round Thread Node Event Details
-
-
+
@@ -138,10 +199,45 @@ let replayCursor = 0;
let workerLogs = {};
let lastTablePos = -1, lastWorkerPos = -1;
+// ── Analytics data ───────────────────────────────────────────────
+let snapRounds = []; // unique tick numbers that have snapshots
+let snapEntries = []; // snapEntries[roundIdx][nodeIdx] = entry count
+let snapPeerCount = []; // snapPeerCount[roundIdx][nodeIdx] = peer count
+let totalKeys = 0; // max entries seen across all snapshots
+let numRounds = 0; // total rounds from trace
+
+let metricPushesSent = new Int32Array(0);
+let metricPushesRecv = new Int32Array(0);
+let metricRedundant = 0;
+let metricTotalPushes = 0;
+let metricNoPeers = 0;
+
+// cumulative push-recv per round: cumulPushRecv[roundIdx][nodeIdx]
+let cumulPushRecv = [];
+// round number for each event index: eventRoundIdx[evtIdx] = roundIdx into snapRounds
+let eventRoundMap = []; // eventRoundMap[evtIdx] = tick
+
+// ── Community detection ──────────────────────────────────────────
+let community = new Int32Array(0); // community[nodeIdx] = community id
+let numCommunities = 0;
+let communityHulls = []; // communityHulls[cid] = [[x,y], ...]
+let communityColors = ['#6366f1','#22c55e','#f59e0b','#ef4444','#3b82f6','#a855f7','#ec4899','#14b8a6'];
+
// ── View transform ───────────────────────────────────────────────
let vx = 0, vy = 0, vs = 1; // view x, y, scale
let isDragging = false, dragX = 0, dragY = 0, dragVx = 0, dragVy = 0;
+// ── Tab switching ────────────────────────────────────────────────
+document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+ btn.classList.add('active');
+ document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
+ if (btn.dataset.tab === 'analytics') drawAllCharts();
+ });
+});
+
// ── Quadtree pool (flat Float64Array, reused across iterations) ──
// Per node: [ox, oy, size, cx, cy, mass, c0, c1, c2, c3, body]
const QF = 11;
@@ -178,10 +274,12 @@ canvas.addEventListener('wheel', (e) => {
drawGraph();
}, { passive: false });
+let clickStartX = 0, clickStartY = 0;
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
dragX = e.clientX; dragY = e.clientY;
+ clickStartX = e.clientX; clickStartY = e.clientY;
dragVx = vx; dragVy = vy;
canvas.style.cursor = 'grabbing';
});
@@ -191,8 +289,12 @@ window.addEventListener('mousemove', (e) => {
vy = dragVy + (e.clientY - dragY);
drawGraph();
});
-window.addEventListener('mouseup', () => {
- if (isDragging) { isDragging = false; canvas.style.cursor = ''; }
+window.addEventListener('mouseup', (e) => {
+ if (isDragging) {
+ const wasDrag = Math.abs(e.clientX - clickStartX) > 3 || Math.abs(e.clientY - clickStartY) > 3;
+ isDragging = false; canvas.style.cursor = '';
+ if (!wasDrag) handleNodeClick(e);
+ }
});
canvas.addEventListener('dblclick', () => { resetView(); drawGraph(); });
@@ -357,6 +459,452 @@ function runForceLayout(w, h) {
});
}
+// ── Node click → detail panel ────────────────────────────────────
+function handleNodeClick(e) {
+ const r = canvas.getBoundingClientRect();
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
+ const wx = (mx - vx) / vs, wy = (my - vy) / vs;
+ const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1))));
+ const hitR = baseR * 2;
+ let best = -1, bestD = hitR * hitR;
+ for (let i = 0; i < N; i++) {
+ const dx = posX[i] - wx, dy = posY[i] - wy;
+ const d2 = dx*dx + dy*dy;
+ if (d2 < bestD) { bestD = d2; best = i; }
+ }
+ const panel = document.getElementById('nodeDetail');
+ if (best < 0) { panel.style.display = 'none'; return; }
+ showNodeDetail(best);
+}
+
+function showNodeDetail(ni) {
+ const panel = document.getElementById('nodeDetail');
+ document.getElementById('ndName').textContent = nodeNames[ni];
+
+ // Find current round from replay position
+ let curRound = 0;
+ if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick;
+
+ // Key count from latest snapshot ≤ current round
+ let keyCount = 0, peerCount = 0;
+ for (let r = snapRounds.length - 1; r >= 0; r--) {
+ if (snapRounds[r] <= curRound) { keyCount = snapEntries[r][ni] || 0; peerCount = snapPeerCount[r][ni] || 0; break; }
+ }
+
+ let html = '';
+ html += '
Community ' + (community[ni] !== undefined ? community[ni] : '-') + '
';
+ html += '
Pushes Sent ' + (metricPushesSent[ni] || 0) + '
';
+ html += '
Pushes Recv ' + (metricPushesRecv[ni] || 0) + '
';
+ html += '
Keys ' + keyCount + '/' + totalKeys + '
';
+ html += '
Peers ' + peerCount + '
';
+ document.getElementById('ndStats').innerHTML = html;
+
+ // Mini event log: last 20 events for this node up to cursor
+ const name = nodeNames[ni];
+ let evHtml = '
Recent events:
';
+ let count = 0;
+ for (let i = Math.min(replayCursor, allEvents.length) - 1; i >= 0 && count < 20; i--) {
+ if (allEvents[i].node === name) {
+ evHtml += '
' + allEvents[i].kind + (allEvents[i].detail ? ': ' + formatDetail(allEvents[i].kind, allEvents[i].detail).replace(/&[lr]arr;/g, '→') : '') + '
';
+ count++;
+ }
+ }
+ document.getElementById('ndEvents').innerHTML = evHtml;
+
+ panel.style.display = 'block';
+}
+
+document.getElementById('nodeDetailClose').addEventListener('click', () => {
+ document.getElementById('nodeDetail').style.display = 'none';
+});
+
+// ── Community detection (BFS on visible edges) ──────────────────
+function detectCommunities() {
+ community = new Int32Array(N).fill(-1);
+ numCommunities = 0;
+ const adj = new Array(N);
+ for (let i = 0; i < N; i++) adj[i] = [];
+ for (let i = 0; i < totalEdges; i++) {
+ adj[edgeSrc[i]].push(edgeDst[i]);
+ adj[edgeDst[i]].push(edgeSrc[i]);
+ }
+ const queue = [];
+ for (let i = 0; i < N; i++) {
+ if (community[i] >= 0) continue;
+ const cid = numCommunities++;
+ community[i] = cid;
+ queue.push(i);
+ while (queue.length > 0) {
+ const u = queue.pop();
+ for (const v of adj[u]) {
+ if (community[v] < 0) { community[v] = cid; queue.push(v); }
+ }
+ }
+ }
+}
+
+// ── Convex hull (Graham scan) ───────────────────────────────────
+function convexHull(points) {
+ if (points.length < 3) return points.slice();
+ points.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+ const cross = (o, a, b) => (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]);
+ const lower = [];
+ for (const p of points) {
+ while (lower.length >= 2 && cross(lower[lower.length-2], lower[lower.length-1], p) <= 0) lower.pop();
+ lower.push(p);
+ }
+ const upper = [];
+ for (let i = points.length - 1; i >= 0; i--) {
+ const p = points[i];
+ while (upper.length >= 2 && cross(upper[upper.length-2], upper[upper.length-1], p) <= 0) upper.pop();
+ upper.push(p);
+ }
+ lower.pop(); upper.pop();
+ return lower.concat(upper);
+}
+
+function computeCommunityHulls() {
+ communityHulls = [];
+ for (let c = 0; c < numCommunities; c++) {
+ const pts = [];
+ for (let i = 0; i < N; i++) {
+ if (community[i] === c) pts.push([posX[i], posY[i]]);
+ }
+ communityHulls.push(pts.length >= 3 ? convexHull(pts) : pts);
+ }
+}
+
+// ── Analytics charts ────────────────────────────────────────────
+function drawAllCharts() {
+ drawBadges();
+ drawConvergenceChart();
+ drawHeatmap();
+ drawLoadHistogram();
+}
+
+function drawBadges() {
+ const grid = document.getElementById('badgeGrid');
+ const hasSnaps = snapRounds.length > 0;
+
+ // Delivery ratio
+ let delivery = 1.0;
+ if (hasSnaps) {
+ const last = snapEntries[snapEntries.length - 1];
+ let full = 0;
+ for (let i = 0; i < N; i++) if (last[i] >= totalKeys && totalKeys > 0) full++;
+ delivery = N > 0 ? full / N : 1;
+ }
+
+ // Convergence round
+ let convRound = hasSnaps ? -1 : 0;
+ if (hasSnaps && totalKeys > 0) {
+ for (let r = 0; r < snapRounds.length; r++) {
+ let allFull = true;
+ for (let i = 0; i < N; i++) { if (snapEntries[r][i] < totalKeys) { allFull = false; break; } }
+ if (allFull) { convRound = snapRounds[r]; break; }
+ }
+ }
+
+ // Redundancy
+ const redundancy = metricTotalPushes > 0 ? metricRedundant / metricTotalPushes : 0;
+
+ // Load balance CV
+ let mean = 0, variance = 0;
+ if (N > 0) {
+ for (let i = 0; i < N; i++) mean += metricPushesRecv[i];
+ mean /= N;
+ for (let i = 0; i < N; i++) { const d = metricPushesRecv[i] - mean; variance += d * d; }
+ variance /= N;
+ }
+ const cv = mean > 0 ? Math.sqrt(variance) / mean : 0;
+
+ // Amplification
+ const amp = N > 0 ? metricTotalPushes / N : 0;
+
+ const logN = N > 1 ? Math.log2(N) : 1;
+ const delColor = delivery >= 0.99 ? 'green' : delivery >= 0.9 ? 'yellow' : 'red';
+ const convColor = convRound < 0 ? 'red' : convRound <= 2 * logN ? 'green' : convRound <= 3 * logN ? 'yellow' : 'red';
+ const redColor = redundancy < 0.2 ? 'green' : redundancy < 0.4 ? 'yellow' : 'red';
+ const cvColor = cv < 0.3 ? 'green' : cv < 0.6 ? 'yellow' : 'red';
+
+ grid.innerHTML =
+ badge(delColor, (delivery * 100).toFixed(1) + '%', 'Delivery') +
+ badge(convColor, convRound < 0 ? 'Never' : 'Round ' + convRound, 'Convergence') +
+ badge(redColor, (redundancy * 100).toFixed(1) + '%', 'Redundancy') +
+ badge(cvColor, 'CV ' + cv.toFixed(2), 'Load Balance') +
+ badge('', metricTotalPushes.toLocaleString(), 'Total Pushes') +
+ badge('', amp.toFixed(2) + '\u00d7', 'Amplification');
+}
+
+function badge(color, val, label) {
+ return '
';
+}
+
+function drawConvergenceChart() {
+ const cv = document.getElementById('convCanvas');
+ if (!snapRounds.length || totalKeys === 0) {
+ cv.style.display = 'none';
+ const sec = document.getElementById('convSection');
+ if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); }
+ return;
+ }
+ cv.style.display = 'block';
+ const sec = document.getElementById('convSection');
+ const emp = sec.querySelector('.chart-empty');
+ if (emp) emp.remove();
+
+ const dpr = devicePixelRatio;
+ const w = cv.parentElement.clientWidth, h = 180;
+ cv.width = w * dpr; cv.height = h * dpr;
+ cv.style.width = w + 'px'; cv.style.height = h + 'px';
+ const c = cv.getContext('2d');
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const pad = { l: 45, r: 12, t: 12, b: 28 };
+ const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b;
+ const maxRound = snapRounds[snapRounds.length - 1] || 1;
+
+ // Background
+ c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h);
+
+ // Grid
+ c.strokeStyle = '#2a2d3a'; c.lineWidth = 1;
+ for (let pct = 0; pct <= 100; pct += 25) {
+ const y = pad.t + ch * (1 - pct / 100);
+ c.beginPath(); c.moveTo(pad.l, y); c.lineTo(pad.l + cw, y); c.stroke();
+ }
+
+ // Compute data points
+ const pts = [];
+ for (let r = 0; r < snapRounds.length; r++) {
+ let full = 0;
+ for (let i = 0; i < N; i++) if (snapEntries[r][i] >= totalKeys) full++;
+ pts.push({ round: snapRounds[r], pct: N > 0 ? full / N * 100 : 0 });
+ }
+
+ // Draw fill
+ c.beginPath();
+ c.moveTo(pad.l, pad.t + ch);
+ for (const p of pts) {
+ const x = pad.l + (p.round / maxRound) * cw;
+ const y = pad.t + ch * (1 - p.pct / 100);
+ c.lineTo(x, y);
+ }
+ c.lineTo(pad.l + (pts[pts.length-1].round / maxRound) * cw, pad.t + ch);
+ c.closePath();
+ c.fillStyle = 'rgba(99,102,241,0.2)'; c.fill();
+
+ // Draw line
+ c.beginPath();
+ for (let i = 0; i < pts.length; i++) {
+ const x = pad.l + (pts[i].round / maxRound) * cw;
+ const y = pad.t + ch * (1 - pts[i].pct / 100);
+ i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
+ }
+ c.strokeStyle = '#6366f1'; c.lineWidth = 2; c.stroke();
+
+ // Current replay round marker
+ let curRound = 0;
+ if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick;
+ const mx = pad.l + (curRound / maxRound) * cw;
+ c.setLineDash([4, 3]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1;
+ c.beginPath(); c.moveTo(mx, pad.t); c.lineTo(mx, pad.t + ch); c.stroke();
+ c.setLineDash([]);
+
+ // Axes labels
+ c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif';
+ c.textAlign = 'right';
+ for (let pct = 0; pct <= 100; pct += 25) {
+ c.fillText(pct + '%', pad.l - 4, pad.t + ch * (1 - pct / 100) + 3);
+ }
+ c.textAlign = 'center';
+ const step = Math.max(1, Math.ceil(maxRound / 8));
+ for (let r = 0; r <= maxRound; r += step) {
+ c.fillText(r, pad.l + (r / maxRound) * cw, h - 6);
+ }
+}
+
+function drawHeatmap() {
+ const cv = document.getElementById('heatCanvas');
+ const tooltip = document.getElementById('heatTooltip');
+ if (!snapRounds.length || totalKeys === 0) {
+ cv.style.display = 'none';
+ const sec = document.getElementById('heatSection');
+ if (!sec.querySelector('.chart-empty')) { const d = document.createElement('div'); d.className = 'chart-empty'; d.textContent = 'No snapshot data'; sec.appendChild(d); }
+ return;
+ }
+ cv.style.display = 'block';
+ const sec = document.getElementById('heatSection');
+ const emp = sec.querySelector('.chart-empty');
+ if (emp) emp.remove();
+
+ const dpr = devicePixelRatio;
+ const w = cv.parentElement.clientWidth;
+ const padL = 60, padR = 8, padT = 4, padB = 24;
+ const cols = snapRounds.length, rows = N;
+ const cellW = Math.max(1, Math.floor((w - padL - padR) / Math.max(cols, 1)));
+ const cellH = N <= 50 ? 14 : Math.max(1, Math.min(4, Math.floor(220 / N)));
+ const h = padT + rows * cellH + padB;
+ cv.width = w * dpr; cv.height = h * dpr;
+ cv.style.width = w + 'px'; cv.style.height = h + 'px';
+ const c = cv.getContext('2d');
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h);
+
+ // Sort nodes by name
+ const sortedIdx = Array.from({length: N}, (_, i) => i);
+ sortedIdx.sort((a, b) => nodeNames[a].localeCompare(nodeNames[b]));
+
+ // Draw cells
+ for (let ri = 0; ri < rows; ri++) {
+ const ni = sortedIdx[ri];
+ for (let ci = 0; ci < cols; ci++) {
+ const frac = totalKeys > 0 ? (snapEntries[ci][ni] || 0) / totalKeys : 0;
+ const g = Math.round(frac * 200);
+ c.fillStyle = 'rgb(' + (255 - g) + ',' + (255 - Math.round(frac * 55)) + ',' + (255 - g) + ')';
+ if (frac > 0) c.fillStyle = 'rgb(' + Math.round(30 + (1-frac)*225) + ',' + Math.round(80 + (1-frac)*175) + ',' + Math.round(30 + (1-frac)*225) + ')';
+ else c.fillStyle = '#2a2d3a';
+ c.fillRect(padL + ci * cellW, padT + ri * cellH, cellW - (cellW > 2 ? 1 : 0), cellH - (cellH > 2 ? 1 : 0));
+ }
+ }
+
+ // Node labels (only if space)
+ if (cellH >= 10) {
+ c.fillStyle = '#9ca3af'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'right';
+ for (let ri = 0; ri < rows; ri++) {
+ c.fillText(nodeNames[sortedIdx[ri]], padL - 3, padT + ri * cellH + cellH - 2);
+ }
+ }
+
+ // Round labels
+ c.fillStyle = '#6b7280'; c.font = '9px system-ui,sans-serif'; c.textAlign = 'center';
+ const labelStep = Math.max(1, Math.ceil(cols / 10));
+ for (let ci = 0; ci < cols; ci += labelStep) {
+ c.fillText(snapRounds[ci], padL + ci * cellW + cellW / 2, h - 6);
+ }
+
+ // Current round marker
+ let curRound = 0;
+ if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick;
+ let markerCol = 0;
+ for (let ci = 0; ci < cols; ci++) { if (snapRounds[ci] <= curRound) markerCol = ci; }
+ const mx = padL + markerCol * cellW + cellW / 2;
+ c.setLineDash([3, 2]); c.strokeStyle = '#f59e0b'; c.lineWidth = 1;
+ c.beginPath(); c.moveTo(mx, padT); c.lineTo(mx, padT + rows * cellH); c.stroke();
+ c.setLineDash([]);
+
+ // Hover tooltip
+ cv.onmousemove = (e) => {
+ const rect = cv.getBoundingClientRect();
+ const ex = e.clientX - rect.left, ey = e.clientY - rect.top;
+ const col = Math.floor((ex - padL) / cellW);
+ const row = Math.floor((ey - padT) / cellH);
+ if (col >= 0 && col < cols && row >= 0 && row < rows) {
+ const ni = sortedIdx[row];
+ const entries = snapEntries[col][ni] || 0;
+ const pct = totalKeys > 0 ? Math.round(entries / totalKeys * 100) : 0;
+ tooltip.textContent = nodeNames[ni] + ' at round ' + snapRounds[col] + ': ' + entries + '/' + totalKeys + ' keys (' + pct + '%)';
+ tooltip.style.display = 'block';
+ tooltip.style.left = (ex + 12) + 'px'; tooltip.style.top = (ey - 20) + 'px';
+ } else { tooltip.style.display = 'none'; }
+ };
+ cv.onmouseleave = () => { tooltip.style.display = 'none'; };
+}
+
+function drawLoadHistogram() {
+ const cv = document.getElementById('loadCanvas');
+ const dpr = devicePixelRatio;
+ const w = cv.parentElement.clientWidth, h = 150;
+ cv.width = w * dpr; cv.height = h * dpr;
+ cv.style.width = w + 'px'; cv.style.height = h + 'px';
+ const c = cv.getContext('2d');
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
+ c.fillStyle = '#1a1d2e'; c.fillRect(0, 0, w, h);
+
+ if (N === 0) return;
+
+ const pad = { l: 40, r: 8, t: 8, b: 24 };
+ const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b;
+
+ // Get data up to current replay pos from cumulative
+ let data;
+ let curRound = 0;
+ if (replayCursor > 0 && replayCursor <= allEvents.length) curRound = allEvents[replayCursor - 1].tick;
+
+ // Find the closest round in cumulPushRecv
+ let bestR = -1;
+ for (let r = 0; r < snapRounds.length; r++) {
+ if (snapRounds[r] <= curRound) bestR = r;
+ }
+
+ if (bestR >= 0 && cumulPushRecv.length > bestR) {
+ data = cumulPushRecv[bestR];
+ } else {
+ data = metricPushesRecv; // fallback: total
+ }
+
+ // Compute stats
+ let maxVal = 0, mean = 0;
+ for (let i = 0; i < N; i++) { if (data[i] > maxVal) maxVal = data[i]; mean += data[i]; }
+ mean /= N;
+ if (maxVal === 0) maxVal = 1;
+ let std = 0;
+ for (let i = 0; i < N; i++) { const d = data[i] - mean; std += d * d; }
+ std = Math.sqrt(std / N);
+
+ // Bin for large N
+ const useBins = N > 100;
+ let barData, barCount;
+ if (useBins) {
+ barCount = Math.min(50, N);
+ barData = new Float64Array(barCount);
+ const binCounts = new Int32Array(barCount);
+ for (let i = 0; i < N; i++) {
+ const bin = Math.min(barCount - 1, Math.floor(i / N * barCount));
+ barData[bin] += data[i]; binCounts[bin]++;
+ }
+ for (let i = 0; i < barCount; i++) if (binCounts[i] > 0) barData[i] /= binCounts[i];
+ // Recompute max
+ maxVal = 0;
+ for (let i = 0; i < barCount; i++) if (barData[i] > maxVal) maxVal = barData[i];
+ if (maxVal === 0) maxVal = 1;
+ } else {
+ barCount = N;
+ barData = data;
+ }
+
+ const barW = Math.max(1, cw / barCount - (barCount < 50 ? 1 : 0));
+
+ for (let i = 0; i < barCount; i++) {
+ const val = barData[i];
+ const barH = (val / maxVal) * ch;
+ const dev = std > 0 ? Math.abs(val - mean) / std : 0;
+ // Color by deviation: green near mean, yellow moderate, red outlier
+ let r, g, b;
+ if (dev < 1) { r = 34; g = 197; b = 94; }
+ else if (dev < 2) { r = 245; g = 158; b = 11; }
+ else { r = 239; g = 68; b = 68; }
+ c.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
+ c.fillRect(pad.l + i * (cw / barCount), pad.t + ch - barH, barW, barH);
+ }
+
+ // Mean line
+ const meanY = pad.t + ch - (mean / maxVal) * ch;
+ c.setLineDash([4, 3]); c.strokeStyle = '#e0e0e0'; c.lineWidth = 1;
+ c.beginPath(); c.moveTo(pad.l, meanY); c.lineTo(pad.l + cw, meanY); c.stroke();
+ c.setLineDash([]);
+
+ // Labels
+ c.fillStyle = '#6b7280'; c.font = '10px system-ui,sans-serif';
+ c.textAlign = 'right';
+ c.fillText(maxVal, pad.l - 4, pad.t + 10);
+ c.fillText('0', pad.l - 4, pad.t + ch);
+ c.textAlign = 'left';
+ c.fillText('mean: ' + mean.toFixed(1), pad.l + 4, meanY - 4);
+ c.textAlign = 'center';
+ c.fillText(useBins ? 'nodes (binned)' : 'node index', pad.l + cw / 2, h - 4);
+}
+
// ── Drawing ──────────────────────────────────────────────────────
function drawGraph() {
const r = canvas.parentElement.getBoundingClientRect();
@@ -371,20 +919,62 @@ function drawGraph() {
// Adaptive sizing
const baseR = Math.max(2, Math.min(8, 400/Math.sqrt(Math.max(N, 1))));
- const showLabels = vs * baseR > 6, showStroke = N <= 200;
+ const showStroke = N <= 200;
+
+ // Semantic zoom levels
+ const showHulls = vs < 0.5 && numCommunities > 1;
+ const showNodes = vs >= 0.2;
+ const showEdges = vs >= 0.2 && !(vs < 0.5 && N > 1000);
+ const showLabels = vs * baseR > 6;
+ const thickDetail = vs > 1.5;
// Flash state
const hasNodeFlash = flashNode >= 0 && now - flashNodeT < 400;
const hasEdgeFlash = flashSrc >= 0 && now - flashEdgeT < 600;
+ // ── Community hulls (zoomed out) ──
+ if (showHulls) {
+ for (let c = 0; c < numCommunities; c++) {
+ const hull = communityHulls[c];
+ if (!hull || hull.length < 2) continue;
+ const color = communityColors[c % communityColors.length];
+ // Expand hull slightly for padding
+ let cx = 0, cy = 0;
+ for (const p of hull) { cx += p[0]; cy += p[1]; }
+ cx /= hull.length; cy /= hull.length;
+ const pad = 20 / vs;
+
+ ctx2d.beginPath();
+ for (let i = 0; i < hull.length; i++) {
+ const dx = hull[i][0] - cx, dy = hull[i][1] - cy;
+ const d = Math.sqrt(dx*dx + dy*dy) || 1;
+ const px = hull[i][0] + dx/d * pad, py = hull[i][1] + dy/d * pad;
+ i === 0 ? ctx2d.moveTo(px, py) : ctx2d.lineTo(px, py);
+ }
+ ctx2d.closePath();
+ ctx2d.fillStyle = color + '18'; ctx2d.fill();
+ ctx2d.strokeStyle = color + '60'; ctx2d.lineWidth = 2/vs; ctx2d.stroke();
+
+ // Label
+ if (vs < 0.5) {
+ let count = 0;
+ for (let i = 0; i < N; i++) if (community[i] === c) count++;
+ ctx2d.fillStyle = color;
+ ctx2d.font = Math.max(12, 16/vs) + 'px system-ui,sans-serif';
+ ctx2d.textAlign = 'center';
+ ctx2d.fillText('Community ' + c + ' (' + count + ' nodes)', cx, cy);
+ }
+ }
+ }
+
// ── Edges ──
- if (!(vs < 0.15 && visEdges > 10000)) {
+ if (showEdges && !(vs < 0.15 && visEdges > 10000)) {
const baseAlpha = Math.min(0.25, 40/Math.sqrt(Math.max(visEdges, 1)));
let flashEdgeI = -1;
ctx2d.beginPath();
ctx2d.strokeStyle = 'rgba(99,102,241,' + baseAlpha + ')';
- ctx2d.lineWidth = 1/vs;
+ ctx2d.lineWidth = (thickDetail ? 1.5 : 1)/vs;
let batched = 0;
for (let i = 0; i < visEdges; i++) {
@@ -408,30 +998,50 @@ function drawGraph() {
}
}
- // ── Nodes (batched) ──
- let flashNodeI = -1;
- ctx2d.beginPath();
- for (let i = 0; i < N; i++) {
- const px = posX[i], py = posY[i];
- if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue;
- if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; }
- ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI);
- }
- ctx2d.fillStyle = '#6366f1'; ctx2d.fill();
- if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
+ // ── Nodes (community-colored) ──
+ if (showNodes) {
+ const useCommunityColor = numCommunities > 1;
+ let flashNodeI = -1;
- if (flashNodeI >= 0) {
- const t = 1-(now-flashNodeT)/400, rad = baseR+6*t;
- ctx2d.beginPath();
- ctx2d.arc(posX[flashNodeI], posY[flashNodeI], rad, 0, 2*Math.PI);
- ctx2d.fillStyle = '#818cf8'; ctx2d.fill();
- if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
+ if (useCommunityColor) {
+ // Batch by community color
+ for (let c = 0; c < numCommunities; c++) {
+ ctx2d.beginPath();
+ for (let i = 0; i < N; i++) {
+ if (community[i] !== c) continue;
+ const px = posX[i], py = posY[i];
+ if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue;
+ if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; }
+ ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI);
+ }
+ ctx2d.fillStyle = communityColors[c % communityColors.length]; ctx2d.fill();
+ if (showStroke) { ctx2d.strokeStyle = '#1a1d2e'; ctx2d.lineWidth = 1/vs; ctx2d.stroke(); }
+ }
+ } else {
+ ctx2d.beginPath();
+ for (let i = 0; i < N; i++) {
+ const px = posX[i], py = posY[i];
+ if (px+baseR < v0x || px-baseR > v1x || py+baseR < v0y || py-baseR > v1y) continue;
+ if (hasNodeFlash && i === flashNode) { flashNodeI = i; continue; }
+ ctx2d.moveTo(px+baseR, py); ctx2d.arc(px, py, baseR, 0, 2*Math.PI);
+ }
+ ctx2d.fillStyle = '#6366f1'; ctx2d.fill();
+ if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
+ }
+
+ if (flashNodeI >= 0) {
+ const t = 1-(now-flashNodeT)/400, rad = baseR+6*t;
+ ctx2d.beginPath();
+ ctx2d.arc(posX[flashNodeI], posY[flashNodeI], rad, 0, 2*Math.PI);
+ ctx2d.fillStyle = '#818cf8'; ctx2d.fill();
+ if (showStroke) { ctx2d.strokeStyle = '#4f46e5'; ctx2d.lineWidth = 1.5/vs; ctx2d.stroke(); }
+ }
}
// ── Labels ──
if (showLabels) {
ctx2d.fillStyle = '#c7d2fe';
- ctx2d.font = Math.max(8, Math.min(11, 11/vs)) + 'px system-ui,sans-serif';
+ ctx2d.font = Math.max(8, Math.min(thickDetail ? 13 : 11, (thickDetail ? 13 : 11)/vs)) + 'px system-ui,sans-serif';
ctx2d.textAlign = 'center';
for (let i = 0; i < N; i++) {
const px = posX[i], py = posY[i];
@@ -595,6 +1205,12 @@ function replayToImpl(pos) {
document.getElementById('replaySlider').value = pos;
document.getElementById('replayPos').textContent = replayCursor + '/' + allEvents.length;
drawGraph();
+ // Update analytics charts if visible
+ if (document.getElementById('tab-analytics').classList.contains('active')) {
+ drawConvergenceChart();
+ drawHeatmap();
+ drawLoadHistogram();
+ }
}
// ── Trace loading ────────────────────────────────────────────────
@@ -609,9 +1225,16 @@ function resetState() {
allEvents = []; replayCursor = 0; workerLogs = {};
lastTablePos = -1; lastWorkerPos = -1;
vx = 0; vy = 0; vs = 1;
+ snapRounds = []; snapEntries = []; snapPeerCount = []; totalKeys = 0; numRounds = 0;
+ metricPushesSent = new Int32Array(0); metricPushesRecv = new Int32Array(0);
+ metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0;
+ cumulPushRecv = []; eventRoundMap = [];
+ community = new Int32Array(0); numCommunities = 0; communityHulls = [];
document.getElementById('eventTableBody').textContent = '';
document.getElementById('workerColumns').innerHTML = '';
document.getElementById('layoutProgress').style.display = 'none';
+ document.getElementById('nodeDetail').style.display = 'none';
+ document.getElementById('badgeGrid').innerHTML = '';
}
async function loadTrace(file) {
@@ -643,12 +1266,26 @@ async function loadTrace(file) {
if (!edgeKeys.has(key)) { edgeKeys.add(key); tempEdges.push(si, di, -1); }
}
- // Build events
+ // Build events + extract snapshots
let seq = 0;
allEvents = [];
+ numRounds = trace.num_rounds || 0;
+
+ // First pass: collect snapshots grouped by tick
+ const snapByTick = new Map(); // tick -> Map(nodeIdx -> {entries, peer_count})
for (const ev of trace.events) {
const isObj = typeof ev.kind === 'object';
- if (isObj && 'StateSnapshot' in ev.kind) continue;
+ if (isObj && 'StateSnapshot' in ev.kind) {
+ const snap = ev.kind.StateSnapshot.snapshot || ev.kind.StateSnapshot;
+ const ni = nodeIdx.get(ev.node_name);
+ if (ni === undefined) continue;
+ const entriesCount = snap.entries ? Object.keys(snap.entries).length : 0;
+ const peerCount = snap.peer_count || 0;
+ if (!snapByTick.has(ev.tick)) snapByTick.set(ev.tick, new Map());
+ snapByTick.get(ev.tick).set(ni, { entries: entriesCount, peer_count: peerCount });
+ if (entriesCount > totalKeys) totalKeys = entriesCount;
+ continue;
+ }
if (!isObj && ev.kind === 'StateSnapshot') continue;
let kind, detail;
if (isObj) { for (kind in ev.kind) break; detail = ev.kind[kind] || null; }
@@ -656,6 +1293,65 @@ async function loadTrace(file) {
allEvents.push({ seq: seq++, tick: ev.tick, node: ev.node_name, thread: ev.thread_name, kind, detail });
}
+ // Build snapshot arrays sorted by round
+ snapRounds = Array.from(snapByTick.keys()).sort((a, b) => a - b);
+ snapEntries = []; snapPeerCount = [];
+ for (const tick of snapRounds) {
+ const eArr = new Int32Array(N);
+ const pArr = new Int32Array(N);
+ const m = snapByTick.get(tick);
+ for (const [ni, d] of m) { eArr[ni] = d.entries; pArr[ni] = d.peer_count; }
+ snapEntries.push(eArr);
+ snapPeerCount.push(pArr);
+ }
+
+ // Compute per-node metrics from events
+ metricPushesSent = new Int32Array(N);
+ metricPushesRecv = new Int32Array(N);
+ metricRedundant = 0; metricTotalPushes = 0; metricNoPeers = 0;
+
+ // Also build cumulative push-recv per round
+ const roundSet = new Set(snapRounds);
+ cumulPushRecv = [];
+ let runningRecv = new Int32Array(N);
+
+ for (let i = 0; i < allEvents.length; i++) {
+ const ev = allEvents[i];
+ const ni = nodeIdx.get(ev.node);
+ if (ni === undefined) continue;
+ if (ev.kind === 'GossipRoundStarted') {
+ metricPushesSent[ni]++;
+ metricTotalPushes++;
+ }
+ if (ev.kind === 'PushReceived') {
+ metricPushesRecv[ni]++;
+ runningRecv[ni]++;
+ if (ev.detail && ev.detail.keys_updated === 0) metricRedundant++;
+ }
+ if (ev.kind === 'GossipRoundNoPeers') { metricNoPeers++; }
+ }
+
+ // Build cumulative recv snapshots aligned to snap rounds
+ // Re-scan to build per-round cumulative
+ if (snapRounds.length > 0) {
+ const cumRecv = new Int32Array(N);
+ let sri = 0;
+ for (let i = 0; i < allEvents.length && sri < snapRounds.length; i++) {
+ const ev = allEvents[i];
+ if (ev.kind === 'PushReceived') {
+ const ni = nodeIdx.get(ev.node);
+ if (ni !== undefined) cumRecv[ni]++;
+ }
+ // When we pass a snapshot round boundary, save
+ while (sri < snapRounds.length && ev.tick >= snapRounds[sri]) {
+ cumulPushRecv.push(new Int32Array(cumRecv));
+ sri++;
+ }
+ }
+ // Fill remaining
+ while (sri < snapRounds.length) { cumulPushRecv.push(new Int32Array(cumRecv)); sri++; }
+ }
+
// Index PeerAdded edges
for (let i = 0; i < allEvents.length; i++) {
const ev = allEvents[i];
@@ -694,9 +1390,14 @@ async function loadTrace(file) {
const rect = canvas.parentElement.getBoundingClientRect();
await runForceLayout(rect.width, rect.height);
+ // Community detection + hulls
+ detectCommunities();
+ computeCommunityHulls();
+
dot.className = 'status-dot ready';
statusText.textContent = trace.name + ' (' + allEvents.length + ' events)';
drawGraph();
+ drawAllCharts();
// Replay controls
document.getElementById('replayControls').style.display = 'flex';