Skip to content

Commit 634e1c8

Browse files
conachepablodeymo
andauthored
fix(forkchoice): redesign viz node weight display (#328)
## Motivation Closes #150 . In the original fork choice viz, node size scaled with `weight`, so a high-weight block could grow large enough to obscure neighboring block roots. This PR replaces it with fixed-size nodes whose inner circle fills from the bottom proportional to `weight / validator_count`. ## Description Every node renders at the same size; the visible inner fill is the weight ratio. The fill animates first, then the role color (head, safe-target, justified, finalized) flips after the fill settles. The tooltip's `weight` line now reads `count/total (pct%)`. ## How to Use ### 1. Start a local devnet ``` make run-devnet ``` Or start a node manually: ``` cargo run --release -- \ --custom-network-config-dir ./config \ --node-key ./keys/node.key \ --node-id 0 \ --metrics-port 5054 ``` ### 2. Open the visualization Navigate to `http://localhost:5054/lean/v0/fork_choice/ui` in your browser. The page will start polling automatically and render the fork tree as blocks are produced. ### What to look for - **Block at the head**: empty orange ring (no attestations yet). - **Recent canonical blocks**: partially filled rings, getting fuller as attestations arrive. - **Justified / finalized blocks**: full discs in blue / green. - **Forks (when they happen)**: competing branches show visibly lower fill than the canonical chain. ## Screenshots ### Live local devnet <img width="667" height="798" alt="Screenshot 2026-04-29 at 22 41 18" src="https://github.com/user-attachments/assets/52e578bd-8ef1-4d8e-b01b-934fe6a7a4f4" /> ### Forked tree (mocked data) <img width="733" height="1120" alt="Screenshot 2026-04-29 at 19 59 14" src="https://github.com/user-attachments/assets/977e9f28-a3cd-42c5-8050-a28313b343f0" /> ## Test plan - [x] Verified locally with a single run — 4 validators, watching head → safe-target → justified → finalized transitions. - [x] `make fmt`, `make lint`, `make test` — no regressions. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent f855f53 commit 634e1c8

1 file changed

Lines changed: 96 additions & 33 deletions

File tree

crates/net/rpc/static/fork_choice.html

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,29 @@
7272
stroke-width: 1.5;
7373
}
7474

75-
.node-circle {
75+
.node-outer {
76+
fill: none;
7677
stroke-width: 2;
7778
cursor: pointer;
7879
}
7980

81+
.node-inner {
82+
stroke: none;
83+
pointer-events: none;
84+
}
85+
86+
.node-hit {
87+
fill: transparent;
88+
stroke: none;
89+
cursor: pointer;
90+
}
91+
92+
.fill-mask {
93+
fill: #1a1a2e;
94+
stroke: none;
95+
pointer-events: none;
96+
}
97+
8098
.node-label {
8199
fill: #ccc;
82100
font-size: 10px;
@@ -149,10 +167,11 @@
149167

150168
<script>
151169
const POLL_INTERVAL = 2000;
152-
const MARGIN = { top: 40, right: 60, bottom: 40, left: 80 };
153-
const SLOT_HEIGHT = 50;
154-
const MIN_RADIUS = 8;
155-
const MAX_RADIUS = 30;
170+
const MARGIN = { top: 40, right: 60, bottom: 140, left: 80 };
171+
const SLOT_HEIGHT = 120;
172+
const MAX_SLOT_HEIGHT = 200;
173+
const NODE_RADIUS = 16;
174+
const INNER_RADIUS = NODE_RADIUS - 3;
156175
const TRANSITION_DURATION = 500;
157176

158177
const COLORS = {
@@ -193,15 +212,9 @@
193212
return COLORS.default;
194213
}
195214

196-
function nodeStroke(node, data) {
197-
const color = nodeColor(node, data);
198-
return d3.color(color).darker(0.5).toString();
199-
}
200-
201-
function nodeRadius(node, validatorCount) {
202-
if (!validatorCount || validatorCount === 0) return MIN_RADIUS;
203-
const ratio = node.weight / validatorCount;
204-
return MIN_RADIUS + ratio * (MAX_RADIUS - MIN_RADIUS);
215+
function weightRatio(node, validatorCount) {
216+
if (!validatorCount) return 0;
217+
return Math.max(0, Math.min(1, node.weight / validatorCount));
205218
}
206219

207220
function isGenesisParent(parentRoot) {
@@ -269,7 +282,12 @@
269282
const maxSlot = d3.max(allDescendants, d => d.data.slot);
270283
const slotRange = maxSlot - minSlot || 1;
271284

272-
const totalHeight = Math.max(slotRange * SLOT_HEIGHT, 200);
285+
// Stretch toward the viewport for short chains (capped at MAX_SLOT_HEIGHT
286+
// per slot); fall back to natural SLOT_HEIGHT for long chains (scrollable).
287+
const containerHeight = Math.max(container.clientHeight - MARGIN.top - MARGIN.bottom, 200);
288+
const naturalHeight = slotRange * SLOT_HEIGHT;
289+
const cappedStretch = slotRange * MAX_SLOT_HEIGHT;
290+
const totalHeight = Math.max(naturalHeight, Math.min(containerHeight, cappedStretch));
273291

274292
allDescendants.forEach(d => {
275293
d.y = MARGIN.top + ((d.data.slot - minSlot) / slotRange) * totalHeight;
@@ -291,8 +309,7 @@
291309
x: d.x,
292310
y: d.y,
293311
_color: nodeColor(d.data, data),
294-
_stroke: nodeStroke(d.data, data),
295-
_radius: nodeRadius(d.data, data.validator_count)
312+
_ratio: weightRatio(d.data, data.validator_count)
296313
}));
297314

298315
const links = [];
@@ -317,18 +334,28 @@
317334
return { nodes: flatNodes, links, width: svgWidth, height: svgHeight, slots };
318335
}
319336

320-
function showTooltip(event, d) {
321-
tooltip.innerHTML =
322-
`<span class="tt-label">root:</span> ${truncateRoot(d.root)}<br>` +
337+
// Tracked so render() can refresh the tooltip on each poll without
338+
// requiring the user to move the mouse.
339+
let hoveredRoot = null;
340+
341+
function tooltipHtml(d, total) {
342+
const pct = total ? parseFloat(((d.weight / total) * 100).toFixed(2)) : 0;
343+
return `<span class="tt-label">root:</span> ${truncateRoot(d.root)}<br>` +
323344
`<span class="tt-label">slot:</span> ${d.slot}<br>` +
324345
`<span class="tt-label">proposer:</span> ${d.proposer_index}<br>` +
325-
`<span class="tt-label">weight:</span> ${d.weight}`;
346+
`<span class="tt-label">weight:</span> ${d.weight}${total != null ? `/${total} (${pct}%)` : ''}`;
347+
}
348+
349+
function showTooltip(event, d) {
350+
hoveredRoot = d.root;
351+
tooltip.innerHTML = tooltipHtml(d, currentData?.validator_count);
326352
tooltip.style.opacity = 1;
327353
tooltip.style.left = (event.clientX + 14) + "px";
328354
tooltip.style.top = (event.clientY - 14) + "px";
329355
}
330356

331357
function hideTooltip() {
358+
hoveredRoot = null;
332359
tooltip.style.opacity = 0;
333360
}
334361

@@ -391,15 +418,19 @@
391418
const linksEnter = links.enter()
392419
.append("line")
393420
.attr("class", "link")
421+
.attr("x1", d => d.source.x)
422+
.attr("y1", d => d.source.y + NODE_RADIUS)
423+
.attr("x2", d => d.source.x)
424+
.attr("y2", d => d.source.y + NODE_RADIUS)
394425
.attr("opacity", 0);
395426

396427
links.merge(linksEnter)
397428
.transition()
398429
.duration(TRANSITION_DURATION)
399430
.attr("x1", d => d.source.x)
400-
.attr("y1", d => d.source.y)
431+
.attr("y1", d => d.source.y + NODE_RADIUS)
401432
.attr("x2", d => d.target.x)
402-
.attr("y2", d => d.target.y)
433+
.attr("y2", d => d.target.y - NODE_RADIUS)
403434
.attr("opacity", 1);
404435

405436
// Nodes
@@ -419,14 +450,30 @@
419450
.attr("opacity", 0);
420451

421452
nodeEnter.append("circle")
422-
.attr("class", "node-circle")
423-
.attr("r", d => d._radius)
424-
.attr("fill", d => d._color)
425-
.attr("stroke", d => d._stroke);
453+
.attr("class", "node-inner")
454+
.attr("r", INNER_RADIUS)
455+
.attr("fill", d => d._color);
456+
457+
nodeEnter.append("rect")
458+
.attr("class", "fill-mask")
459+
.attr("x", -INNER_RADIUS)
460+
.attr("width", INNER_RADIUS * 2)
461+
.attr("y", -INNER_RADIUS)
462+
.attr("height", d => (1 - d._ratio) * INNER_RADIUS * 2);
463+
464+
nodeEnter.append("circle")
465+
.attr("class", "node-outer")
466+
.attr("r", NODE_RADIUS)
467+
.attr("stroke", d => d._color);
468+
469+
// Invisible hit target so hover works regardless of fill level.
470+
nodeEnter.append("circle")
471+
.attr("class", "node-hit")
472+
.attr("r", NODE_RADIUS);
426473

427474
nodeEnter.append("text")
428475
.attr("class", "node-label")
429-
.attr("dy", d => d._radius + 14)
476+
.attr("dy", NODE_RADIUS + 14)
430477
.text(d => truncateRoot(d.root));
431478

432479
const nodeMerged = nodeEnter.merge(nodeGroups);
@@ -442,17 +489,33 @@
442489
.attr("transform", d => `translate(${d.x},${d.y})`)
443490
.attr("opacity", 1);
444491

445-
nodeMerged.select("circle")
492+
// Fill animates first, then color flips once the fill has settled.
493+
nodeMerged.select(".fill-mask")
446494
.transition()
447495
.duration(TRANSITION_DURATION)
448-
.attr("r", d => d._radius)
449-
.attr("fill", d => d._color)
450-
.attr("stroke", d => d._stroke);
496+
.attr("height", d => (1 - d._ratio) * INNER_RADIUS * 2);
497+
498+
nodeMerged.select(".node-inner")
499+
.transition()
500+
.delay(TRANSITION_DURATION)
501+
.duration(100)
502+
.attr("fill", d => d._color);
503+
504+
nodeMerged.select(".node-outer")
505+
.transition()
506+
.delay(TRANSITION_DURATION)
507+
.duration(100)
508+
.attr("stroke", d => d._color);
451509

452510
nodeMerged.select("text")
453-
.attr("dy", d => d._radius + 14)
454511
.text(d => truncateRoot(d.root));
455512

513+
// Keep the tooltip live while the user holds the mouse still over a node.
514+
if (hoveredRoot) {
515+
const hovered = layout.nodes.find(n => n.root === hoveredRoot);
516+
if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data.validator_count);
517+
}
518+
456519
// Auto-scroll to head node
457520
const headNode = layout.nodes.find(n => n.root === data.head);
458521
if (headNode) {

0 commit comments

Comments
 (0)