From 7d0a7576b11362b65cb283ced8b06c642383eae7 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 20:25:30 -0400 Subject: [PATCH 01/16] feat(docs): add event log sidebar with inline state inspection to architecture viz Adds a persistent left sidebar showing chronological event history with color-coded dots, expandable inline node state snapshots, auto-scroll, and toggle via E key or button. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 2356 ++++++++++++++++++++++++++++++++++++ 1 file changed, 2356 insertions(+) create mode 100644 docs/architecture-viz.html diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html new file mode 100644 index 000000000..73374a684 --- /dev/null +++ b/docs/architecture-viz.html @@ -0,0 +1,2356 @@ + + + + + +Peerbit Architecture Visualization + + + + + + +
+ Peerbit Architecture + +
+
+ + Transport +
+
+ + PubSub +
+
+ + Blocks +
+
+ + Ranges +
+
+ + Entries +
+
+ +
+ + +
+ + +
+
+ Event Log + +
+
+
+ + +
+ +
+ + +
Click a node to connect to — press Esc to cancel
+ + +
+
+ + +
+ + +
+
+ + + + + +
+ +
+ Events: 0 +
+
+
+ +
+
Ready — right-click the canvas to add nodes or use the button above
+
+ + +
+
+ +

Peerbit Architecture Visualization

+

Interactive visualization of Peerbit's P2P database architecture showing data flow across all layers.

+

Mouse Controls

+
    +
  • Drag nodes to reposition them
  • +
  • Scroll to zoom in/out
  • +
  • Drag on empty space to pan
  • +
  • Right-click a node for actions
  • +
  • Right-click empty space to add a node
  • +
  • Double-click a node to open its inspector
  • +
+

Keyboard Shortcuts

+
    +
  • N — Add a new node
  • +
  • Space — Play/Pause timeline
  • +
  • / — Step backward/forward
  • +
  • Home / End — Jump to start/end
  • +
  • + / - — Zoom in/out
  • +
  • E — Toggle Event Log sidebar
  • +
  • ? — Toggle this help
  • +
  • Esc — Cancel connection / close menus
  • +
+

Architecture Layers

+
    +
  • Transport — libp2p connections (TCP/WS/WebRTC)
  • +
  • PubSub — DirectSub topic subscriptions & messages
  • +
  • Blocks — DirectBlock content-addressed storage
  • +
  • Ranges — Replication range assignments (0..1 circular space)
  • +
  • Entries — Log entries with DAG structure
  • +
+

Peerbit Data Flow

+

When peers connect: Transport → PubSub ready → Blocks ready → Exchange heads → Replicate entries.

+

SharedLog entries are assigned coordinates in a circular 0..1 space. Each peer's replication range determines which entries it stores.

+
+
+ + + + From 0087e99698cc3026a7f42457970af4c04eedac81 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 20:28:16 -0400 Subject: [PATCH 02/16] feat(docs): show state diffs instead of full snapshots in event sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded events now show only what changed between the previous and current state using a recursive diff with old→new highlighting, making it easy to see exactly what each event mutated. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 180 ++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 3 deletions(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index 73374a684..f4b64b174 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -280,6 +280,15 @@ .event-row .snapshot-node-label { font-weight: 600; font-size: 11px; margin-bottom: 2px; padding: 2px 0; } +.diff-old { color: var(--red); text-decoration: line-through; font-size: 10px; } +.diff-arrow { color: var(--text-dim); margin: 0 3px; font-size: 10px; } +.diff-new { color: var(--green); } +.diff-added .tree-key { color: var(--green); } +.diff-added .tree-value, .diff-added .tree-type { color: var(--green); } +.diff-removed .tree-key { color: var(--red); text-decoration: line-through; } +.diff-removed .tree-value, .diff-removed .tree-type { color: var(--red); text-decoration: line-through; } +.diff-changed > .tree-row { background: rgba(227,179,65,0.08); } +.diff-no-changes { color: var(--text-dim); font-style: italic; padding: 4px 12px; font-size: 11px; } .sidebar-hidden #event-sidebar { display: none; } .sidebar-hidden #canvas-container, .sidebar-hidden #timeline-panel { left: 0 !important; } @@ -1015,13 +1024,22 @@

Peerbit Data Flow

} }, + previousSnapshots: new Map(), // nodeId → last known state for diffing + pushEvent(evt) { - // Capture state snapshots for affected nodes + // Capture state snapshots and diffs for affected nodes const affectedIds = getAffectedNodeIds(evt.details); evt.nodeSnapshots = {}; + evt.nodeDiffs = {}; for (const nid of affectedIds) { const node = this.nodes.get(nid); - if (node) evt.nodeSnapshots[nid] = buildNodeStateTree(node); + if (node) { + const current = buildNodeStateTree(node); + const previous = this.previousSnapshots.get(nid); + evt.nodeSnapshots[nid] = current; + evt.nodeDiffs[nid] = previous ? diffState(previous, current) : null; + this.previousSnapshots.set(nid, current); + } } this.eventLog.push(evt); @@ -1112,6 +1130,7 @@

Peerbit Data Flow

this.pendingEvents = []; this.particles = []; this.checkpoints = []; + this.previousSnapshots.clear(); for (const [id] of this.inspectors) this.closeInspector(id); this.inspectors.clear(); nextNodeId = 0; nextConnId = 0; nextProgramId = 0; nextEntryId = 0; @@ -1927,6 +1946,140 @@

Peerbit Data Flow

return [...ids]; } +// Recursive state diff: returns a diff tree describing changes +// Each node is { type: 'unchanged'|'added'|'removed'|'changed'|'object', ... } +function diffState(oldObj, newObj) { + if (oldObj === newObj) return { type: 'unchanged', value: newObj }; + if (oldObj === null || oldObj === undefined || newObj === null || newObj === undefined) { + if (oldObj == null && newObj == null) return { type: 'unchanged', value: newObj }; + if (oldObj == null) return { type: 'added', value: newObj }; + if (newObj == null) return { type: 'removed', value: oldObj }; + } + if (typeof oldObj !== typeof newObj) { + return { type: 'changed', oldValue: oldObj, newValue: newObj }; + } + if (typeof oldObj !== 'object') { + return oldObj === newObj + ? { type: 'unchanged', value: newObj } + : { type: 'changed', oldValue: oldObj, newValue: newObj }; + } + // Both are objects/arrays + const oldIsArray = Array.isArray(oldObj); + const newIsArray = Array.isArray(newObj); + if (oldIsArray !== newIsArray) { + return { type: 'changed', oldValue: oldObj, newValue: newObj }; + } + if (oldIsArray) { + // Array diff by index + const children = []; + const maxLen = Math.max(oldObj.length, newObj.length); + let hasChanges = false; + for (let i = 0; i < maxLen; i++) { + if (i >= oldObj.length) { + children.push({ key: i, diff: { type: 'added', value: newObj[i] } }); + hasChanges = true; + } else if (i >= newObj.length) { + children.push({ key: i, diff: { type: 'removed', value: oldObj[i] } }); + hasChanges = true; + } else { + const child = diffState(oldObj[i], newObj[i]); + children.push({ key: i, diff: child }); + if (child.type !== 'unchanged') hasChanges = true; + } + } + return { type: hasChanges ? 'array' : 'unchanged', value: newObj, children, hasChanges }; + } + // Object diff + const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + const children = []; + let hasChanges = false; + for (const key of allKeys) { + if (!(key in oldObj)) { + children.push({ key, diff: { type: 'added', value: newObj[key] } }); + hasChanges = true; + } else if (!(key in newObj)) { + children.push({ key, diff: { type: 'removed', value: oldObj[key] } }); + hasChanges = true; + } else { + const child = diffState(oldObj[key], newObj[key]); + children.push({ key, diff: child }); + if (child.type !== 'unchanged') hasChanges = true; + } + } + return { type: hasChanges ? 'object' : 'unchanged', value: newObj, children, hasChanges }; +} + +// Render a diff tree node, only showing paths with changes +function renderDiffTree(key, diff, depth) { + if (depth === undefined) depth = 0; + const container = document.createElement('div'); + container.className = 'tree-node'; + + if (diff.type === 'unchanged') { + // Skip unchanged subtrees entirely for compact view + return null; + } + + if (diff.type === 'added') { + container.classList.add('diff-added'); + container.appendChild(renderTreeNode(key, diff.value, depth < 2, depth)); + return container; + } + + if (diff.type === 'removed') { + container.classList.add('diff-removed'); + container.appendChild(renderTreeNode(key, diff.value, false, depth)); + return container; + } + + if (diff.type === 'changed') { + // Leaf-level change: show old → new + const row = document.createElement('div'); + row.className = 'tree-row'; + container.classList.add('diff-changed'); + const formatVal = (v) => typeof v === 'string' ? `"${v}"` : String(v); + const typeClass = (v) => typeof v === 'string' ? 'string' : typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'boolean' : ''; + row.innerHTML = `${key !== '' ? `${escapeHtml(String(key))}:` : ''}${escapeHtml(formatVal(diff.oldValue))}${escapeHtml(formatVal(diff.newValue))}`; + container.appendChild(row); + return container; + } + + // Object or array with some changes — show only changed children + if (diff.type === 'object' || diff.type === 'array') { + const isArray = diff.type === 'array'; + const changedChildren = diff.children.filter(c => c.diff.type !== 'unchanged'); + const label = isArray ? `Array(${diff.children.length})` : `{${diff.children.length}}`; + const changedLabel = `${changedChildren.length} changed`; + + const row = document.createElement('div'); + row.className = 'tree-row'; + const expanded = depth < 1; + row.innerHTML = `${expanded ? '▼' : '▶'}${key !== '' ? `${escapeHtml(String(key))}:` : ''}${label} ${changedLabel}`; + + const childrenEl = document.createElement('div'); + childrenEl.className = 'tree-children'; + childrenEl.style.display = expanded ? 'block' : 'none'; + + for (const child of changedChildren) { + const childEl = renderDiffTree(String(child.key), child.diff, depth + 1); + if (childEl) childrenEl.appendChild(childEl); + } + + row.querySelector('.tree-toggle').addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = childrenEl.style.display !== 'none'; + childrenEl.style.display = isOpen ? 'none' : 'block'; + row.querySelector('.tree-toggle').textContent = isOpen ? '▶' : '▼'; + }); + + container.appendChild(row); + container.appendChild(childrenEl); + return container; + } + + return null; +} + let sidebarUserScrolledUp = false; (function initSidebarScroll() { @@ -1986,7 +2139,28 @@

Peerbit Data Flow

label.style.color = node ? node.color : 'var(--text)'; label.textContent = node ? node.displayName : 'Node ' + nid; snapshot.appendChild(label); - snapshot.appendChild(renderTreeNode('', state, true)); + + const diff = evt.nodeDiffs && evt.nodeDiffs[nid]; + if (diff && diff.hasChanges) { + // Render only the changed fields as a diff tree + const diffEl = renderDiffTree('', diff, 0); + if (diffEl) { + snapshot.appendChild(diffEl); + } else { + const noChange = document.createElement('div'); + noChange.className = 'diff-no-changes'; + noChange.textContent = 'No state changes'; + snapshot.appendChild(noChange); + } + } else if (!diff) { + // First event for this node — show full state + snapshot.appendChild(renderTreeNode('', state, true)); + } else { + const noChange = document.createElement('div'); + noChange.className = 'diff-no-changes'; + noChange.textContent = 'No state changes'; + snapshot.appendChild(noChange); + } } } else { const noData = document.createElement('div'); From 886f127be4add5e103dae936dde6391c2d4f1185 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 20:32:07 -0400 Subject: [PATCH 03/16] fix(docs): prevent event sidebar tree content from overflowing Reduce tree indentation and padding inside sidebar snapshots, allow flex-wrap on tree rows, and add overflow-x auto on the snapshot container so expanded diffs fit within the 280px sidebar. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index f4b64b174..70e03878a 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -275,19 +275,24 @@ .event-row .event-snapshot { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); font-size: 11px; + overflow-x: auto; } .event-row.expanded .event-snapshot { display: block; } .event-row .snapshot-node-label { font-weight: 600; font-size: 11px; margin-bottom: 2px; padding: 2px 0; } +/* Compact tree inside sidebar: reduce padding/indent */ +.event-snapshot .tree-node { padding: 0 2px; } +.event-snapshot .tree-children { padding-left: 10px; } +.event-snapshot .tree-row { flex-wrap: wrap; font-size: 10px; } .diff-old { color: var(--red); text-decoration: line-through; font-size: 10px; } .diff-arrow { color: var(--text-dim); margin: 0 3px; font-size: 10px; } -.diff-new { color: var(--green); } +.diff-new { color: var(--green); font-size: 10px; } .diff-added .tree-key { color: var(--green); } .diff-added .tree-value, .diff-added .tree-type { color: var(--green); } .diff-removed .tree-key { color: var(--red); text-decoration: line-through; } .diff-removed .tree-value, .diff-removed .tree-type { color: var(--red); text-decoration: line-through; } -.diff-changed > .tree-row { background: rgba(227,179,65,0.08); } +.diff-changed > .tree-row { background: rgba(227,179,65,0.08); border-radius: 3px; } .diff-no-changes { color: var(--text-dim); font-style: italic; padding: 4px 12px; font-size: 11px; } .sidebar-hidden #event-sidebar { display: none; } .sidebar-hidden #canvas-container, .sidebar-hidden #timeline-panel { left: 0 !important; } From a99d5d013dc5defa0b193e40a62c25bc3df276c4 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 20:34:07 -0400 Subject: [PATCH 04/16] fix(docs): stop tree toggle clicks from collapsing event sidebar rows Clicks inside the snapshot container now call stopPropagation so expanding/collapsing tree nodes no longer bubbles up to the row's expand/collapse handler. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index 70e03878a..2dbba4b23 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -2174,9 +2174,12 @@

Peerbit Data Flow

snapshot.appendChild(noData); } + // Stop clicks inside snapshot from collapsing the row + snapshot.addEventListener('click', (e) => { e.stopPropagation(); }); + row.appendChild(snapshot); - // Click to toggle expand + // Click to toggle expand (only fires from summary, not snapshot) row.addEventListener('click', () => { row.classList.toggle('expanded'); }); From 4b4fb2dc69cd7293edaf5f07428803bbd87c8b72 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:01:17 -0400 Subject: [PATCH 05/16] feat(docs): make timeline slider replay/rewind simulation state The timeline slider now fully restores simulation state when scrubbed. stateHistory[] stores JSON-serialized snapshots after each event, and restoreToEvent() deserializes them to rewind/replay nodes, connections, and counters. Event markers are aligned to slider positions using matching scale math and 7px thumb-width inset. Seek-then-act truncates future events. Play/cycleSpeed use seekTo() for actual state restore. Sidebar highlights the active event row. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 134 ++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 10 deletions(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index 2dbba4b23..ac0931194 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -100,7 +100,7 @@ -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; border: 2px solid var(--bg-panel); } -#event-markers { position: absolute; top: 0; left: 0; right: 0; height: 8px; pointer-events: none; } +#event-markers { position: absolute; top: 0; left: 7px; right: 7px; height: 8px; pointer-events: none; } /* Event description */ #event-description { @@ -296,6 +296,7 @@ .diff-no-changes { color: var(--text-dim); font-style: italic; padding: 4px 12px; font-size: 11px; } .sidebar-hidden #event-sidebar { display: none; } .sidebar-hidden #canvas-container, .sidebar-hidden #timeline-panel { left: 0 !important; } +.event-row.active { border-left: 3px solid var(--accent); background: rgba(74,158,255,0.06); } @@ -596,6 +597,7 @@

Peerbit Data Flow

// State snapshots for timeline scrubbing checkpoints: [], checkpointInterval: 50, + stateHistory: [], // stateHistory[i] = JSON string of full state after event i addNode(overrides = {}) { const cx = canvas.width / (2 * devicePixelRatio); @@ -1032,6 +1034,9 @@

Peerbit Data Flow

previousSnapshots: new Map(), // nodeId → last known state for diffing pushEvent(evt) { + // Truncate future if we've seeked backward and are now acting + this.truncateToCurrentTimeline(); + // Capture state snapshots and diffs for affected nodes const affectedIds = getAffectedNodeIds(evt.details); evt.nodeSnapshots = {}; @@ -1056,6 +1061,62 @@

Peerbit Data Flow

if (this.eventLog.length % this.checkpointInterval === 0) { this.saveCheckpoint(); } + + // Capture full state snapshot for timeline scrubbing + this.stateHistory.push(JSON.stringify({ + nodesData: [...this.nodes.entries()], + connectionsData: [...this.connections.entries()], + counters: { nextNodeId, nextConnId, nextProgramId, nextEntryId }, + }, mapReplacer)); + }, + + truncateToCurrentTimeline() { + const keep = timeline.currentIndex + 1; + if (this.eventLog.length > keep) { + this.eventLog.length = keep; + this.stateHistory.length = keep; + // Truncate sidebar DOM children + const list = document.getElementById('event-list'); + while (list && list.children.length > keep) { + list.removeChild(list.lastChild); + } + // Rebuild event markers with correct positions + timeline.rebuildEventMarkers(); + } + }, + + restoreToEvent(index) { + if (index < 0 || index >= this.stateHistory.length) return; + const snap = JSON.parse(this.stateHistory[index], mapReviver); + + // Restore core state + this.nodes = new Map(snap.nodesData); + this.connections = new Map(snap.connectionsData); + nextNodeId = snap.counters.nextNodeId; + nextConnId = snap.counters.nextConnId; + nextProgramId = snap.counters.nextProgramId; + nextEntryId = snap.counters.nextEntryId; + + // Clear transient state (closures reference stale objects) + this.particles = []; + this.pendingEvents = []; + + // Rebuild previousSnapshots from restored nodes for correct future diffs + this.previousSnapshots.clear(); + for (const [nid, node] of this.nodes) { + this.previousSnapshots.set(nid, buildNodeStateTree(node)); + } + + // Close inspectors for nodes that no longer exist, update remaining + for (const [nid] of this.inspectors) { + if (!this.nodes.has(nid)) { + this.closeInspector(nid); + } else { + this.updateInspector(nid); + } + } + + renderer.syncForceLayoutQuiet(); }, saveCheckpoint() { @@ -1135,6 +1196,7 @@

Peerbit Data Flow

this.pendingEvents = []; this.particles = []; this.checkpoints = []; + this.stateHistory = []; this.previousSnapshots.clear(); for (const [id] of this.inspectors) this.closeInspector(id); this.inspectors.clear(); @@ -1152,6 +1214,14 @@

Peerbit Data Flow

return value; } +function mapReviver(key, value) { + if (value && typeof value === 'object') { + if (value.__type === 'Map') return new Map(value.entries); + if (value.__type === 'Set') return new Set(value.values); + } + return value; +} + /* ============================================================ CANVAS RENDERER — D3 force layout + Canvas 2D drawing ============================================================ */ @@ -1232,6 +1302,25 @@

Peerbit Data Flow

setTimeout(() => this.simulation.stop(), 3000); }, + syncForceLayoutQuiet() { + const nodes = [...engine.nodes.values()].map(n => ({ + id: n.id, + x: n.position.x, + y: n.position.y, + fx: n.position.x, + fy: n.position.y, + })); + + const links = [...engine.connections.values()].map(c => ({ + source: c.fromNodeId, + target: c.toNodeId, + })); + + this.simulation.nodes(nodes); + this.simulation.force('link').links(links); + this.simulation.stop(); + }, + syncPositions() { const simNodes = this.simulation.nodes(); for (const sn of simNodes) { @@ -2219,7 +2308,7 @@

Peerbit Data Flow

slider.value = engine.eventLog.length - 1; this.currentIndex = engine.eventLog.length - 1; this.updateDisplay(); - this.addEventMarker(evt); + this.rebuildEventMarkers(); }, updateDisplay() { @@ -2234,19 +2323,48 @@

Peerbit Data Flow

addEventMarker(evt) { const markers = document.getElementById('event-markers'); - const total = Math.max(1, engine.eventLog.length); + // Use length-1 to match slider scale (max = length-1) + const max = Math.max(1, engine.eventLog.length - 1); + const index = markers.children.length; const marker = document.createElement('div'); - marker.style.cssText = `position:absolute;width:3px;height:8px;border-radius:1px;background:${evt.color};left:${(evt.id / total * 100)}%;`; + marker.style.cssText = `position:absolute;width:3px;height:8px;border-radius:1px;background:${evt.color};left:${(index / max * 100)}%;`; markers.appendChild(marker); // Limit markers while (markers.children.length > 200) markers.removeChild(markers.firstChild); }, + rebuildEventMarkers() { + const markers = document.getElementById('event-markers'); + if (!markers) return; + markers.innerHTML = ''; + // Use length-1 to match slider scale (max = length-1) + const max = Math.max(1, engine.eventLog.length - 1); + for (let i = 0; i < engine.eventLog.length; i++) { + const e = engine.eventLog[i]; + const marker = document.createElement('div'); + marker.style.cssText = `position:absolute;width:3px;height:8px;border-radius:1px;background:${e.color};left:${(i / max * 100)}%;`; + markers.appendChild(marker); + } + }, + seekTo(index) { this.currentIndex = Math.max(0, Math.min(engine.eventLog.length - 1, index)); document.getElementById('timeline-slider').value = this.currentIndex; this.updateDisplay(); + engine.restoreToEvent(this.currentIndex); + + // Highlight current event row in sidebar + const list = document.getElementById('event-list'); + if (list) { + const prev = list.querySelector('.event-row.active'); + if (prev) prev.classList.remove('active'); + const rows = list.children; + if (rows[this.currentIndex]) { + rows[this.currentIndex].classList.add('active'); + rows[this.currentIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } }, togglePlay() { @@ -2256,9 +2374,7 @@

Peerbit Data Flow

if (this.playing) { this.playInterval = setInterval(() => { if (this.currentIndex < engine.eventLog.length - 1) { - this.currentIndex++; - document.getElementById('timeline-slider').value = this.currentIndex; - this.updateDisplay(); + this.seekTo(this.currentIndex + 1); } else { this.togglePlay(); } @@ -2291,9 +2407,7 @@

Peerbit Data Flow

clearInterval(this.playInterval); this.playInterval = setInterval(() => { if (this.currentIndex < engine.eventLog.length - 1) { - this.currentIndex++; - document.getElementById('timeline-slider').value = this.currentIndex; - this.updateDisplay(); + this.seekTo(this.currentIndex + 1); } else { this.togglePlay(); } From 0cb17129a6a6e8be5eaa3a469e5deb6d01f4c0e4 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:19:20 -0400 Subject: [PATCH 06/16] fix(docs): replace inline handlers shadowed by document.timeline Inline onclick/oninput handlers on timeline buttons and slider resolved `timeline` to `document.timeline` (Web Animations API DocumentTimeline) instead of the lexical `const timeline` object, silently breaking all slider and button interactions. Switch to addEventListener calls that correctly close over the lexical scope. Also fix Map mutation during iteration in restoreToEvent and guard seekTo against empty event log. Adds Playwright browser test verifying slider state replay end-to-end. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 29 +++-- docs/test-timeline-playwright.mjs | 178 ++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 docs/test-timeline-playwright.mjs diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index ac0931194..17e9e8022 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -365,19 +365,19 @@
- - - - - + + + + +
- +
Events: 0
- +
Ready — right-click the canvas to add nodes or use the button above
@@ -1108,7 +1108,7 @@

Peerbit Data Flow

} // Close inspectors for nodes that no longer exist, update remaining - for (const [nid] of this.inspectors) { + for (const nid of [...this.inspectors.keys()]) { if (!this.nodes.has(nid)) { this.closeInspector(nid); } else { @@ -2349,6 +2349,7 @@

Peerbit Data Flow

}, seekTo(index) { + if (engine.eventLog.length === 0) return; this.currentIndex = Math.max(0, Math.min(engine.eventLog.length - 1, index)); document.getElementById('timeline-slider').value = this.currentIndex; this.updateDisplay(); @@ -2428,6 +2429,18 @@

Peerbit Data Flow

}, }; +// Bind timeline controls — cannot use inline handlers because `document.timeline` +// (Web Animations API) shadows the lexical `timeline` variable in inline scope. +document.getElementById('timeline-slider').addEventListener('input', function() { + timeline.seekTo(+this.value); +}); +document.getElementById('skip-start-btn').addEventListener('click', () => timeline.skipToStart()); +document.getElementById('step-back-btn').addEventListener('click', () => timeline.stepBack()); +document.getElementById('play-btn').addEventListener('click', () => timeline.togglePlay()); +document.getElementById('step-fwd-btn').addEventListener('click', () => timeline.stepForward()); +document.getElementById('skip-end-btn').addEventListener('click', () => timeline.skipToEnd()); +document.getElementById('speed-btn').addEventListener('click', () => timeline.cycleSpeed()); + /* ============================================================ TOASTS ============================================================ */ diff --git a/docs/test-timeline-playwright.mjs b/docs/test-timeline-playwright.mjs new file mode 100644 index 000000000..bab0d99f4 --- /dev/null +++ b/docs/test-timeline-playwright.mjs @@ -0,0 +1,178 @@ +/** + * Playwright test for timeline slider state replay. + * Run: node docs/test-timeline-playwright.mjs + */ +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let failures = 0; +function assert(cond, msg) { + if (!cond) { console.error('FAIL:', msg); failures++; } + else { console.log(' OK:', msg); } +} + +async function run() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + // Collect console errors + const consoleErrors = []; + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + page.on('pageerror', err => { + consoleErrors.push(err.message); + }); + + const filePath = resolve(__dirname, 'architecture-viz.html'); + await page.goto('file://' + filePath); + await page.waitForTimeout(500); + + // --- Test: Page loads without JS errors --- + console.log('\n--- Test: Page loads without errors ---'); + assert(consoleErrors.length === 0, 'No console errors on load: ' + (consoleErrors.length > 0 ? consoleErrors.join('; ') : 'clean')); + + // --- Load scenario --- + console.log('\n--- Test: Load scenario ---'); + const options = await page.evaluate(() => { + const sel = document.getElementById('scenario-select'); + return [...sel.options].map((o, i) => ({ index: i, value: o.value, text: o.text })); + }); + console.log(' Scenarios:', options.map(o => o.text).join(', ')); + + if (options.length > 1) { + await page.selectOption('#scenario-select', options[1].value); + } + await page.waitForTimeout(3000); // Wait for scenario events + + const eventCount = await page.evaluate(() => engine.eventLog.length); + console.log(' Events after scenario:', eventCount); + assert(eventCount > 3, 'Scenario generated events (got ' + eventCount + ')'); + + const stateHistoryLen = await page.evaluate(() => engine.stateHistory.length); + console.log(' stateHistory.length:', stateHistoryLen); + assert(stateHistoryLen === eventCount, 'stateHistory.length (' + stateHistoryLen + ') matches eventLog.length (' + eventCount + ')'); + + // --- Test: Slider max --- + console.log('\n--- Test: Slider max matches events ---'); + const sliderMax = await page.evaluate(() => +document.getElementById('timeline-slider').max); + assert(sliderMax === eventCount - 1, 'slider.max (' + sliderMax + ') === eventCount-1 (' + (eventCount - 1) + ')'); + + // --- Record full state for comparison --- + const fullNodeCount = await page.evaluate(() => engine.nodes.size); + const fullConnCount = await page.evaluate(() => engine.connections.size); + console.log(' Full state: ' + fullNodeCount + ' nodes, ' + fullConnCount + ' connections'); + + // --- Test: seekTo(0) --- + console.log('\n--- Test: seekTo(0) restores first event state ---'); + consoleErrors.length = 0; + + await page.evaluate(() => timeline.seekTo(0)); + + const seek0Errors = [...consoleErrors]; + const seek0Nodes = await page.evaluate(() => engine.nodes.size); + const seek0Conns = await page.evaluate(() => engine.connections.size); + const seek0Counter = await page.evaluate(() => document.getElementById('event-counter').textContent); + const seek0Desc = await page.evaluate(() => document.getElementById('event-description').textContent); + const seek0Index = await page.evaluate(() => timeline.currentIndex); + + console.log(' Errors:', seek0Errors.length > 0 ? seek0Errors.join('; ') : 'none'); + console.log(' currentIndex:', seek0Index); + console.log(' Counter text:', seek0Counter); + console.log(' Description:', seek0Desc); + console.log(' Nodes:', seek0Nodes, ' Connections:', seek0Conns); + + assert(seek0Errors.length === 0, 'No errors during seekTo(0)'); + assert(seek0Index === 0, 'currentIndex is 0'); + assert(seek0Counter.includes('1 /'), 'Counter shows event 1 (got: ' + seek0Counter + ')'); + assert(seek0Desc.length > 5, 'Description is not empty'); + assert(seek0Nodes <= 1, 'At event 0, at most 1 node (got ' + seek0Nodes + ')'); + assert(seek0Conns === 0, 'At event 0, 0 connections (got ' + seek0Conns + ')'); + + // --- Test: seekTo(end) restores full state --- + console.log('\n--- Test: seekTo(end) restores full state ---'); + consoleErrors.length = 0; + + await page.evaluate(() => timeline.seekTo(engine.eventLog.length - 1)); + + const endNodes = await page.evaluate(() => engine.nodes.size); + const endConns = await page.evaluate(() => engine.connections.size); + + assert(endNodes === fullNodeCount, 'End nodes (' + endNodes + ') === full (' + fullNodeCount + ')'); + assert(endConns === fullConnCount, 'End conns (' + endConns + ') === full (' + fullConnCount + ')'); + assert(consoleErrors.length === 0, 'No errors during seekTo(end)'); + + // --- Test: Slider input event --- + console.log('\n--- Test: Slider oninput triggers seekTo ---'); + consoleErrors.length = 0; + + await page.evaluate(() => { + const slider = document.getElementById('timeline-slider'); + slider.value = '0'; + slider.dispatchEvent(new Event('input')); + }); + + const inputIndex = await page.evaluate(() => timeline.currentIndex); + const inputNodes = await page.evaluate(() => engine.nodes.size); + const inputCounter = await page.evaluate(() => document.getElementById('event-counter').textContent); + + assert(inputIndex === 0, 'Slider input set currentIndex to 0 (got ' + inputIndex + ')'); + assert(inputNodes <= 1, 'Slider input restored state (nodes=' + inputNodes + ')'); + assert(inputCounter.includes('1 /'), 'Counter updated from slider (got: ' + inputCounter + ')'); + assert(consoleErrors.length === 0, 'No errors from slider input: ' + consoleErrors.join('; ')); + + // --- Test: stepForward / stepBack --- + console.log('\n--- Test: Step forward/back ---'); + await page.evaluate(() => timeline.seekTo(0)); + await page.evaluate(() => timeline.stepForward()); + const fwdIndex = await page.evaluate(() => timeline.currentIndex); + assert(fwdIndex === 1, 'stepForward → index 1 (got ' + fwdIndex + ')'); + + await page.evaluate(() => timeline.stepBack()); + const backIndex = await page.evaluate(() => timeline.currentIndex); + assert(backIndex === 0, 'stepBack → index 0 (got ' + backIndex + ')'); + + // --- Test: Sidebar highlighting --- + console.log('\n--- Test: Sidebar event highlighting ---'); + await page.evaluate(() => timeline.seekTo(3)); + const activeCount = await page.evaluate(() => document.querySelectorAll('.event-row.active').length); + const activeIdx = await page.evaluate(() => { + const rows = document.getElementById('event-list').children; + for (let i = 0; i < rows.length; i++) { + if (rows[i].classList.contains('active')) return i; + } + return -1; + }); + assert(activeCount === 1, 'Exactly 1 active event row (got ' + activeCount + ')'); + assert(activeIdx === 3, 'Active row is index 3 (got ' + activeIdx + ')'); + + // --- Test: State increases monotonically through events --- + console.log('\n--- Test: Node count increases through early events ---'); + let prevNodes = 0; + let monotonicOk = true; + for (let i = 0; i < Math.min(eventCount, 5); i++) { + await page.evaluate((idx) => timeline.seekTo(idx), i); + const n = await page.evaluate(() => engine.nodes.size); + if (n < prevNodes) { monotonicOk = false; console.log(' Event ' + i + ': nodes=' + n + ' < prev=' + prevNodes); } + prevNodes = n; + } + assert(monotonicOk, 'Node count is non-decreasing in first 5 events'); + + // --- Final error check --- + console.log('\n--- Final error check ---'); + assert(consoleErrors.length === 0, 'No accumulated JS errors'); + + await browser.close(); + + console.log('\n' + (failures === 0 ? 'ALL TESTS PASSED' : `${failures} FAILURE(S)`)); + process.exit(failures === 0 ? 0 : 1); +} + +run().catch(e => { + console.error('Test runner error:', e); + process.exit(1); +}); From d9224743ab5eff12ef4c79d96ba85dd9f582333a Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:28:35 -0400 Subject: [PATCH 07/16] feat(docs): left-click opens context menu and auto-expand active event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace right-click with short left-click for context menus on nodes, connections, and empty canvas. Dragging still works — menu only shows when mouse hasn't moved more than 3px between down and up. Active event row in the sidebar now auto-expands to show the state diff when stepping through the timeline, and uses orange highlight to stand out. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 46 ++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index 17e9e8022..f37fbd12a 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -296,7 +296,9 @@ .diff-no-changes { color: var(--text-dim); font-style: italic; padding: 4px 12px; font-size: 11px; } .sidebar-hidden #event-sidebar { display: none; } .sidebar-hidden #canvas-container, .sidebar-hidden #timeline-panel { left: 0 !important; } -.event-row.active { border-left: 3px solid var(--accent); background: rgba(74,158,255,0.06); } +.event-row.active { border-left: 3px solid var(--orange); background: rgba(255,136,68,0.12); } +.event-row.active .event-type-label { color: var(--orange); } +.event-row.active .event-desc { color: var(--text-bright); } @@ -1398,13 +1400,17 @@

Peerbit Data Flow

const node = this.hitTestNode(sx, sy); if (node) { this.dragging = node; + this.dragStartScreen = { x: sx, y: sy }; + this.didDrag = false; this.dragOffset = { x: node.position.x - this.screenToWorld(sx, sy).x, y: node.position.y - this.screenToWorld(sx, sy).y, }; } else { this.panning = true; + this.didPan = false; this.panStart = { x: e.clientX - this.transform.x, y: e.clientY - this.transform.y }; + this.panClickPos = { x: e.clientX, y: e.clientY }; } }, @@ -1414,6 +1420,9 @@

Peerbit Data Flow

const sy = e.clientY - rect.top; if (this.dragging) { + const dx = sx - this.dragStartScreen.x; + const dy = sy - this.dragStartScreen.y; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.didDrag = true; const { x, y } = this.screenToWorld(sx, sy); this.dragging.position.x = x + this.dragOffset.x; this.dragging.position.y = y + this.dragOffset.y; @@ -1425,6 +1434,9 @@

Peerbit Data Flow

simNode.fy = this.dragging.position.y; } } else if (this.panning) { + const dx = e.clientX - this.panClickPos.x; + const dy = e.clientY - this.panClickPos.y; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.didPan = true; this.transform.x = e.clientX - this.panStart.x; this.transform.y = e.clientY - this.panStart.y; } else { @@ -1436,10 +1448,28 @@

Peerbit Data Flow

onMouseUp(e) { if (this.dragging) { + const node = this.dragging; // Release fixed position - const simNode = this.simulation.nodes().find(n => n.id === this.dragging.id); + const simNode = this.simulation.nodes().find(n => n.id === node.id); if (simNode) { simNode.fx = null; simNode.fy = null; } this.dragging = null; + // Short click (no drag) → show context menu + if (!this.didDrag) { + showNodeContextMenu(e.clientX, e.clientY, node); + this._justShowedMenu = true; + } + } + if (this.panning && !this.didPan) { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const conn = this.hitTestConnection(sx, sy); + if (conn) { + showConnectionContextMenu(e.clientX, e.clientY, conn); + } else { + showCanvasContextMenu(e.clientX, e.clientY, this.screenToWorld(sx, sy)); + } + this._justShowedMenu = true; } this.panning = false; }, @@ -2355,14 +2385,18 @@

Peerbit Data Flow

this.updateDisplay(); engine.restoreToEvent(this.currentIndex); - // Highlight current event row in sidebar + // Highlight and expand current event row in sidebar const list = document.getElementById('event-list'); if (list) { const prev = list.querySelector('.event-row.active'); - if (prev) prev.classList.remove('active'); + if (prev) { + prev.classList.remove('active'); + prev.classList.remove('expanded'); + } const rows = list.children; if (rows[this.currentIndex]) { rows[this.currentIndex].classList.add('active'); + rows[this.currentIndex].classList.add('expanded'); rows[this.currentIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } @@ -2650,6 +2684,10 @@

Peerbit Data Flow

// Close context menu on click outside document.addEventListener('click', (e) => { + if (renderer._justShowedMenu) { + renderer._justShowedMenu = false; + return; + } if (!e.target.closest('#context-menu') && !e.target.closest('#connect-submenu')) { hideContextMenu(); } From 709e68bb87afa10a9bdbddbca24688636c4b7522 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:34:59 -0400 Subject: [PATCH 08/16] fix(docs): preserve node positions during timeline replay restoreToEvent now saves current settled positions before deserializing the snapshot and re-applies them to nodes that still exist. This prevents nodes from jumping to their mid-force-simulation positions captured at event time. Newly appearing nodes use their original snapshot positions. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index f37fbd12a..c67e199ae 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -1089,6 +1089,13 @@

Peerbit Data Flow

restoreToEvent(index) { if (index < 0 || index >= this.stateHistory.length) return; + + // Preserve current settled positions so nodes don't jump + const savedPositions = new Map(); + for (const [nid, node] of this.nodes) { + savedPositions.set(nid, { x: node.position.x, y: node.position.y }); + } + const snap = JSON.parse(this.stateHistory[index], mapReviver); // Restore core state @@ -1099,6 +1106,15 @@

Peerbit Data Flow

nextProgramId = snap.counters.nextProgramId; nextEntryId = snap.counters.nextEntryId; + // Re-apply settled positions to nodes that still exist + for (const [nid, node] of this.nodes) { + const saved = savedPositions.get(nid); + if (saved) { + node.position.x = saved.x; + node.position.y = saved.y; + } + } + // Clear transient state (closures reference stale objects) this.particles = []; this.pendingEvents = []; From e317727df4092ea7ab80880799fa6ca1fb9b640a Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:42:44 -0400 Subject: [PATCH 09/16] feat(docs): persist simulation state to localStorage across refresh Save eventLog, stateHistory, and timeline position to localStorage after each event (debounced 500ms) and on beforeunload. On page load, restore the full state including sidebar, timeline markers, and node positions. Loading a new scenario clears saved state via reset(). Silently handles quota-exceeded errors. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index c67e199ae..ef012ae81 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -2709,11 +2709,112 @@

Peerbit Data Flow

} }); +/* ============================================================ + LOCAL STORAGE PERSISTENCE + ============================================================ */ + +const STORAGE_KEY = 'peerbit-viz-state'; + +function saveToLocalStorage() { + try { + const data = JSON.stringify({ + eventLog: engine.eventLog, + stateHistory: engine.stateHistory, + currentIndex: timeline.currentIndex, + }); + localStorage.setItem(STORAGE_KEY, data); + } catch (e) { + // Quota exceeded or other error — silently ignore + } +} + +function loadFromLocalStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return false; + const data = JSON.parse(raw); + if (!data.eventLog || !data.stateHistory || data.eventLog.length === 0) return false; + + // Restore the final state snapshot to get nodes/connections/counters + const lastSnap = JSON.parse(data.stateHistory[data.stateHistory.length - 1], mapReviver); + engine.nodes = new Map(lastSnap.nodesData); + engine.connections = new Map(lastSnap.connectionsData); + nextNodeId = lastSnap.counters.nextNodeId; + nextConnId = lastSnap.counters.nextConnId; + nextProgramId = lastSnap.counters.nextProgramId; + nextEntryId = lastSnap.counters.nextEntryId; + + engine.eventLog = data.eventLog; + engine.stateHistory = data.stateHistory; + + // Rebuild previousSnapshots from current nodes + engine.previousSnapshots.clear(); + for (const [nid, node] of engine.nodes) { + engine.previousSnapshots.set(nid, buildNodeStateTree(node)); + } + + // Rebuild sidebar + document.getElementById('event-list').innerHTML = ''; + for (const evt of engine.eventLog) { + addEventToSidebar(evt); + } + + // Set up timeline + const slider = document.getElementById('timeline-slider'); + slider.max = engine.eventLog.length - 1; + const idx = Math.min(data.currentIndex || engine.eventLog.length - 1, engine.eventLog.length - 1); + timeline.currentIndex = idx; + slider.value = idx; + timeline.updateDisplay(); + timeline.rebuildEventMarkers(); + + // Restore to the saved timeline position + engine.restoreToEvent(idx); + + renderer.updateForceLayout(); + showToast('Restored previous session', '#4a9eff'); + return true; + } catch (e) { + console.warn('Failed to restore from localStorage:', e); + localStorage.removeItem(STORAGE_KEY); + return false; + } +} + +function clearSavedState() { + localStorage.removeItem(STORAGE_KEY); +} + +// Debounced auto-save after each event +let _saveTimer = null; +function scheduleSave() { + clearTimeout(_saveTimer); + _saveTimer = setTimeout(saveToLocalStorage, 500); +} + +// Hook into pushEvent — save after each event +const _origPushEvent = engine.pushEvent.bind(engine); +engine.pushEvent = function(evt) { + _origPushEvent(evt); + scheduleSave(); +}; + +// Clear saved state on reset (new scenario or manual reset) +const _origReset = engine.reset.bind(engine); +engine.reset = function() { + _origReset(); + clearSavedState(); +}; + +// Save on page unload +window.addEventListener('beforeunload', saveToLocalStorage); + /* ============================================================ INITIALIZATION ============================================================ */ renderer.init(); +loadFromLocalStorage(); From 552c0de7f18d6e42023cd2b26607d1eaa998d6a3 Mon Sep 17 00:00:00 2001 From: Faolain Date: Sat, 7 Feb 2026 21:57:03 -0400 Subject: [PATCH 10/16] feat(docs): add Clear, Export, and Import buttons for scenario management Adds toolbar buttons to clear the canvas (with confirmation), export simulation state as a downloadable JSON file, and import state via file upload or pasted JSON through a modal dialog. Refactors loadFromLocalStorage to reuse a shared importState() function. Co-Authored-By: Claude Opus 4.6 --- docs/architecture-viz.html | 207 ++++++++++++++++++++++++++++++------- 1 file changed, 170 insertions(+), 37 deletions(-) diff --git a/docs/architecture-viz.html b/docs/architecture-viz.html index ef012ae81..daa0b2bd3 100644 --- a/docs/architecture-viz.html +++ b/docs/architecture-viz.html @@ -226,6 +226,28 @@ cursor: pointer; font-size: 18px; } +/* Import modal */ +#import-modal { + display: none; position: absolute; inset: 0; z-index: 600; + background: rgba(0,0,0,0.7); align-items: center; justify-content: center; +} +#import-modal.visible { display: flex; } +#import-modal-content { + background: var(--bg-panel); border: 1px solid var(--border); border-radius: 12px; + padding: 24px; width: 480px; max-width: 90%; +} +#import-modal-content h3 { color: var(--text-bright); margin-bottom: 8px; font-size: 14px; } +#import-modal-content p { color: var(--text-dim); margin-bottom: 16px; font-size: 12px; } +#import-file-name { color: var(--text-dim); font-size: 11px; margin-left: 8px; } +#import-textarea { + display: block; width: 100%; height: 140px; margin: 12px 0 16px; + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); font-family: var(--font); font-size: 11px; padding: 8px; + resize: vertical; +} +#import-textarea:focus { outline: none; border-color: var(--accent); } +#import-modal-actions { display: flex; justify-content: flex-end; gap: 8px; } + /* Connection mode indicator */ #connection-mode-indicator { display: none; position: absolute; top: 56px; left: 50%; @@ -335,6 +357,11 @@ +
+ + + +
@@ -424,6 +451,21 @@

Peerbit Data Flow

+ +
+
+

Import State

+

Upload a JSON file or paste exported JSON below.

+ + + +
+ + +
+
+
+ + + + + + +
+ Peerbit Architecture (Phase 2) + +
+
+ + Transport +
+
+ + PubSub +
+
+ + Blocks +
+
+ + Ranges +
+
+ + Entries +
+
+ +
+ + + + +
+ + +
+ + +
+
+ Event Log + +
+
+
+ + + +
+ +
+ + +
Click a node to connect to — press Esc to cancel
+ + +
+
+ + +
+ + +
+ + +
+
+ + + + + +
+ +
+ Events: 0 +
+
+
+ +
+
Ready — right-click the canvas to add nodes or use the button above
+
+ + +
+
+ +

Peerbit Architecture Visualization (Phase 2)

+

Interactive visualization of Peerbit's P2P database architecture showing data flow across all layers.

+

Mouse Controls

+
    +
  • Drag nodes to reposition them
  • +
  • Scroll to zoom in/out
  • +
  • Drag on empty space to pan
  • +
  • Right-click a node for actions
  • +
  • Right-click empty space to add a node
  • +
  • Double-click a node to open its inspector
  • +
+

Keyboard Shortcuts

+
    +
  • N — Add a new node
  • +
  • Space — Play/Pause timeline
  • +
  • / — Step backward/forward
  • +
  • Home / End — Jump to start/end
  • +
  • + / - — Zoom in/out
  • +
  • E — Toggle Event Log sidebar
  • +
  • ? — Toggle this help
  • +
  • Esc — Cancel connection / close menus
  • +
+

Architecture Layers

+
    +
  • Transport — libp2p connections (TCP/WS/WebRTC)
  • +
  • PubSub — DirectSub topic subscriptions & messages
  • +
  • Blocks — DirectBlock content-addressed storage
  • +
  • Ranges — Replication range assignments (0..1 circular space)
  • +
  • Entries — Log entries with DAG structure
  • +
+

Peerbit Data Flow

+

When peers connect: Transport → PubSub/Blocks negotiate in parallel → (both ready) Exchange heads.

+

When appending: entries are sent directly to the leader replicators whose ranges cover the entry coordinate.

+

SharedLog entries are assigned coordinates in a circular 0..1 space. Each peer's replication range determines which entries it stores.

+
+
+ + +
+
+

Import State

+

Upload a JSON file or paste exported JSON below.

+ + + +
+ + +
+
+
+ + + + diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 000000000..f06a356bb --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,181 @@ +# Architecture Visualization — Implementation Plan (Phase 2) + +Findings from comparing `docs/architecture-viz.html` against the real Peerbit codebase. +Goal: bring the viz closer to the actual system behavior. + +--- + +## Accuracy Audit + +### What the viz gets right +- Layered architecture: transport, pubsub, blocks +- Programs with shared logs subscribing to topics for peer discovery +- Replication ranges in circular coordinate space with wraparound +- Bidirectional information exchange when peers connect +- Documents as put/delete operations on top of SharedLog +- DAG-linked entries with heads tracking + +### Critical inaccuracies to fix + +| # | Issue | Viz behavior | Real behavior | Priority | +|---|-------|-------------|---------------|----------| +| 1 | Replication targeting | Floods entries to all connected peers with same program | Leader-based targeted delivery — entries sent only to replicators whose ranges cover the entry's coordinates | **High** | +| 2 | Cascading replication | Peer relays to its other peers (gossip) | Originator sends directly to all relevant leaders; no relay chain | **High** | +| 3 | Connection cascade | Sequential: transport → pubsub → blocks with fixed offsets | Pubsub and blocks are independent protocols negotiating in parallel | **Medium** | +| 4 | Program addressing | Random string (`program-`) | Content-addressed CID from serialized program | **Low** | +| 5 | Range space | Float `[0, 1]` | Integer u32 `[0, 4294967295]` or u64 | **Low** (conceptually equivalent) | + +--- + +## Implementation Tasks + +### Phase 2a — Fix replication model (High priority) + +#### 2a.1 Leader-based entry delivery +Replace the current `replicateEntry()` flood with leader computation: +- When appending an entry, compute its coordinate (already exists) +- Find which replicators' ranges cover that coordinate (`rangeContains()` already exists) +- Send the entry only to those leaders, not to all connected peers +- Update `replicateEntry(sourceNodeId, programAddress, entry)` to: + 1. Collect all replication ranges across all nodes for the program + 2. Filter to ranges that contain `entry.coordinate` + 3. Send only to nodes owning those ranges (that are reachable via connections) +- Add event type `replication:leader-send` to distinguish from generic replication + +#### 2a.2 Remove cascading replication +- Delete the cascading relay logic in `replicateEntry()` (the inner loop at lines 951-964) +- Delete `replicateEntryDirect()` (only used for cascading) +- Instead, have the originator iterate all known leaders and send directly +- If a leader is not directly connected, show a "no route" indicator or skip (defer multi-hop to Phase 2c) + +#### 2a.3 Visualize leader selection +- When an entry is appended, briefly highlight the leaders on the canvas (flash their replication arcs) +- Show particle animations only to the targeted leaders, not all peers +- Add a tooltip or event description showing why each leader was chosen ("range [0.2..0.5] covers coordinate 0.35") + +### Phase 2b — Fix connection model (Medium priority) + +#### 2b.1 Parallel protocol negotiation +- Change `addConnection()` so pubsub and blocks negotiate independently (not sequentially) +- Both start after transport is established, with independent random latencies +- Replace the fixed `+30ms` / `+60ms` offsets with two independent timers starting from transport-ready +- Head exchange triggers when **both** pubsub and blocks are ready (not just blocks) + +#### 2b.2 Connection state display +- Show protocol negotiation status on connection hover: "transport: ready, pubsub: negotiating, blocks: ready" +- Dashed line segments during negotiation, solid when fully connected + +### Phase 2c — Add missing systems (Lower priority) + +#### 2c.1 RPC layer abstraction +- Add visual indication that messages go through an RPC layer +- Show message types in particles: `ExchangeHeadsMessage`, `AllReplicatingSegmentsMessage`, `RequestReplicationInfoMessage` +- Color-code particles by message type + +#### 2c.2 Synchronizer (simplified) +- After initial head exchange, run a periodic "sync check" between connected peers +- Compare entry sets and request missing entries +- Show sync status in inspector: "synced with: [Alice, Bob]", "pending: [Carol]" +- Event types: `sync:request`, `sync:complete` + +#### 2c.3 Pruning protocol +- If a node holds an entry outside its replication range, schedule a prune check +- Send prune request to peers: "Can I drop entry X? Do you have it?" +- If confirmed, remove entry from local store +- Event types: `prune:request`, `prune:confirm`, `prune:drop` +- Show pruned entries fading out in the inspector + +#### 2c.4 Adaptive replication +- Add a "load" simulation to nodes (memory/CPU proxy) +- Replication range width adjusts based on load: high load → narrower range, low load → wider range +- Animate range arc width changes +- Show the PID controller concept in inspector: "target width: 0.33, current: 0.28, adjusting..." + +#### 2c.5 Multi-hop routing +- If a leader is not directly connected but reachable via intermediate nodes, route through them +- Show multi-hop particle paths traversing intermediate connections +- Event type: `route:relay` + +### Phase 2d — Polish (Low priority) + +#### 2d.1 Content-addressed program IDs +- Generate program addresses as CID-like hashes instead of random strings +- Show in inspector: "address: bafy..." + +#### 2d.2 Entry types +- Support `APPEND` and `CUT` entry types +- `CUT` entries render with strikethrough in inspector +- Document delete creates a `CUT` entry instead of a regular append + +#### 2d.3 Delivery modes +- Add delivery mode selection in context menu: Acknowledge / Silent / Seek +- Acknowledge mode: show return particle (ACK) from leader back to sender +- Silent mode: no ACK particle + +#### 2d.4 GID grouping +- Group entries by GID in inspector view +- Show GID-based leader computation: "GID g-abc123 → leaders: [Alice, Bob]" + +--- + +## Files to modify +- `docs/architecture-viz.html` — baseline (Phase 1), keep unchanged +- `docs/architecture-viz-phase2.html` — Phase 2 parallel implementation (all Phase 2 changes go here) + +## Key functions to change +- `engine.replicateEntry()` — replace flood with leader targeting (2a.1, 2a.2) +- `engine.replicateEntryDirect()` — remove (2a.2) +- `engine.addConnection()` — parallel protocol negotiation (2b.1) +- `engine.exchangeHeadsIfNeeded()` — add replication segment exchange (2c.1) +- `engine.appendEntry()` — add leader computation before replication (2a.1) +- `renderer.drawParticle()` — message type labels (2c.1) +- `renderer.drawNode()` — leader highlight flash (2a.3) +- New: `engine.findLeaders(coordinate, programAddress)` — compute leaders from ranges (2a.1) +- New: `engine.syncCheck(nodeIdA, nodeIdB)` — simplified synchronizer (2c.2) +- New: `engine.pruneEntry(nodeId, entryHash)` — pruning protocol (2c.3) + +--- + +## Reference: Real implementation files +- `packages/clients/peerbit/src/peer.ts` — `Peerbit.dial()`, `open()` +- `packages/transport/stream/src/index.ts` — `DirectStream`, connection lifecycle +- `packages/transport/pubsub/src/index.ts` — `DirectSub`, subscriptions +- `packages/transport/blocks/src/libp2p.ts` — `DirectBlock` +- `packages/programs/program/program/src/program.ts` — `Program` base class lifecycle +- `packages/programs/data/shared-log/src/index.ts` — `SharedLog.append()`, `handleSubscriptionChange()`, `onMessage()` +- `packages/programs/data/shared-log/src/exchange-heads.ts` — `ExchangeHeadsMessage` +- `packages/programs/data/shared-log/src/ranges.ts` — `ReplicationRangeIndexable`, segment math +- `packages/programs/data/shared-log/src/replication.ts` — `MinReplicas`, replication messages +- `packages/programs/data/document/document/src/program.ts` — `Documents.put()`, `del()` + +--- + +## Parallel Implementation Artifact + +Baseline (Phase 1): `docs/architecture-viz.html` (do not edit) + +Parallel implementation (Phase 2): `docs/architecture-viz-phase2.html` + +--- + +## Running Log + +### Learnings +- 2026-02-08: In `docs/architecture-viz.html`, `exchangeHeadsIfNeeded()` currently syncs missing entries by directly calling `replicateEntryDirect()` (so `replicateEntryDirect()` is not only used for cascading replication today). +- 2026-02-08: `openProgram()` triggers head exchange against any connection with `conn.state === 'connected'` (set at transport-ready), even when `pubsub`/`blocks` negotiation is not yet complete. +- 2026-02-08: Connection visualization currently only differentiates `conn.state` (`connecting` vs `connected`) and partially uses `conn.layers.blocks` for alpha; there is no existing connection hover/tooltip system. +- 2026-02-08: Timeline scrubbing (`timeline.seekTo`) restores snapshots and only re-spawns animations for a small subset of event types; new “leader send” visuals need explicit handling so they show up while stepping/scrubbing. + +### Ahas +- 2026-02-08: The cleanest “parallel implementation” is a new HTML file that starts as a copy of `docs/architecture-viz.html`, so Phase 2 changes can iterate without breaking the baseline demo. +- 2026-02-08: To make leader-selection visuals actually visible during timeline stepping, hook the effects to `replication:leader-send` in `timeline.seekTo` (similar to how `replication:entry-replicate` spawns particles today). + +### Answers To Questions +- 2026-02-08: “Do not edit the existing one” interpreted as: keep `docs/architecture-viz.html` unchanged; implement Phase 2 behaviors in a new `docs/architecture-viz-phase2.html` file. + +### Next Steps +- Done: Create `docs/architecture-viz-phase2.html` by copying the baseline file. +- Done: Phase 2a leader-based replication targeting (and remove cascading replication) implemented in `docs/architecture-viz-phase2.html`. +- Done: Phase 2b connection protocol negotiation in parallel (and gate head exchange on both pubsub+blocks ready) implemented in `docs/architecture-viz-phase2.html`. +- Done: Leader-selection visualization (range arc flash), `replication:leader-send` event, and connection hover status tooltip implemented in `docs/architecture-viz-phase2.html`. +- Next: Implement Phase 2c.1 (RPC message types) by tagging particles/events with message-type labels and updating the particle renderer accordingly. From 6d80f983991ddf80f9d79f26d30c94e13e46d08c Mon Sep 17 00:00:00 2001 From: Faolain Date: Sun, 8 Feb 2026 03:43:20 -0400 Subject: [PATCH 16/16] docs(viz): label layer ownership --- docs/architecture-viz-phase2.html | 35 ++++++++++++++++++++++--------- docs/implementation-plan.md | 8 +++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/architecture-viz-phase2.html b/docs/architecture-viz-phase2.html index 11424a91e..f93fe1c36 100644 --- a/docs/architecture-viz-phase2.html +++ b/docs/architecture-viz-phase2.html @@ -53,6 +53,16 @@ .layer-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 4px 8px; border-radius: 4px; } .layer-toggle:hover { background: var(--bg-panel-hover); } .layer-dot { width: 8px; height: 8px; border-radius: 50%; } +.layer-pill { + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 10px; + line-height: 1.4; + color: var(--text-dim); + background: rgba(255,255,255,0.02); + white-space: nowrap; +} .layer-toggle.off .layer-dot { opacity: 0.3; } .layer-toggle.off { color: var(--text-dim); } @@ -349,25 +359,30 @@ Peerbit Architecture (Phase 2)
-
+
Transport + libp2p
-
+
PubSub + Peerbit
-
+
Blocks + Peerbit
-
+
Ranges + Peerbit
-
+
Entries + Peerbit