Skip to content

Commit 692f201

Browse files
conachepablodeymo
andauthored
fix(forkchoice-viz): expose all role overlaps per block (lambdaclass#341)
## 🗒️ Description / Motivation Each block in the fork choice tree can hold multiple roles at the same time - the latest block is usually both the head and the safe target, for instance. Until now the visualization only painted one color per block, picked by a fixed priority (head > safe target > justified > finalized), so whichever roles weren't on top got silently dropped. That made it impossible to tell from the visualisation that a block was both head and safe target, or to find the safe target at all when it coincided with the head. This PR makes every role visible. The primary color (the inner filled circle) now follows a different priority - **finalized > justified > safe target > head** (strongest commitment first) and any additional roles the block holds are drawn as concentric rings around the primary one, each in that role's color. The tooltip's `status:` line lists all the roles, with each name colored to match its ring. **Preview:** <img width="427" height="473" alt="Screenshot 2026-05-01 at 16 43 16" src="https://github.com/user-attachments/assets/f674d31f-853c-4dc7-b290-3130d60da024" /> <img width="474" height="780" alt="Screenshot 2026-05-01 at 17 10 44" src="https://github.com/user-attachments/assets/8b88af48-01a8-473a-8327-094e604c1bcd" /> <img width="301" height="170" alt="Screenshot 2026-05-01 at 17 10 51" src="https://github.com/user-attachments/assets/46bf1695-8514-4664-9013-5941dd88745d" /> ## What Changed - **New `nodeRoles(node, data)` helper** returning all roles a block holds, ordered by *natural priority* (`finalized > justified > safeTarget > head` — strongest commitment first). The first entry drives the primary color; the rest become halo rings. - **Concentric halo rings** outside the primary ring, one per secondary role, in that role's color. Always present in the DOM (transparent when unused) so role changes between polls just update stroke. Offsets `[6, 12, 18]` give comfortable spacing. - **Tooltip `status:` line** lists all roles for the hovered block, each colored with its role color (e.g., `safe target, head` rendered in yellow + orange). ## Correctness / Behavior Guarantees - **Viz-only change.** No consensus or weight-computation changes. - **Primary color priority flipped** vs. main: previously `head > safe_target > justified > finalized`; now `finalized > justified > safeTarget > head`. A block that is both head + safe_target (the common case) now renders **yellow primary with an orange halo** rather than the previous pure-orange (which shadowed safe_target). ## Tests Added / Run - No tests (client-side rendering only). - Manual: ran a local single-node devnet (4 validators), confirmed halos render correctly when head + safe_target overlap, tooltip lists multiple colored roles, justified-only and finalized-only blocks render as expected, no layout shift between polls. ## Related Issues / PRs - Closes lambdaclass#334 --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent e60b96b commit 692f201

1 file changed

Lines changed: 98 additions & 27 deletions

File tree

crates/net/rpc/static/fork_choice.html

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@
100100
cursor: pointer;
101101
}
102102

103+
.halo {
104+
fill: none;
105+
stroke-width: 2;
106+
pointer-events: none;
107+
}
108+
103109
.node-inner {
104110
stroke: none;
105111
pointer-events: none;
@@ -205,6 +211,21 @@
205211
default: "#666"
206212
};
207213

214+
const ROLE_LABELS = {
215+
finalized: "finalized",
216+
justified: "justified",
217+
safeTarget: "safe target",
218+
head: "head"
219+
};
220+
221+
// Concentric halos drawn outside the primary ring, one per secondary role.
222+
const HALO_OFFSETS = [6, 12, 18];
223+
const MAX_HALO_OFFSET = HALO_OFFSETS[HALO_OFFSETS.length - 1];
224+
// Reserve label space below the outermost halo so the gap between the
225+
// outermost circle and the label stays constant across all nodes,
226+
// regardless of how many halos are drawn.
227+
const LABEL_DY = NODE_RADIUS + MAX_HALO_OFFSET + 14;
228+
208229
const container = document.getElementById("chart-container");
209230
const tooltip = document.getElementById("tooltip");
210231
const emptyMsg = document.getElementById("empty-message");
@@ -233,12 +254,16 @@
233254
return root.length > 10 ? root.slice(0, 10) : root;
234255
}
235256

236-
function nodeColor(node, data) {
237-
if (node.root === data.head) return COLORS.head;
238-
if (node.root === data.safe_target) return COLORS.safeTarget;
239-
if (node.root === data.justified.root) return COLORS.justified;
240-
if (node.slot <= data.finalized.slot) return COLORS.finalized;
241-
return COLORS.default;
257+
// Returns the roles that apply to a block, in natural priority order
258+
// (strongest commitment first). The first entry drives the primary color;
259+
// the rest become halos around it.
260+
function nodeRoles(node, data) {
261+
const roles = [];
262+
if (node.slot <= data.finalized.slot) roles.push("finalized");
263+
if (node.root === data.justified.root) roles.push("justified");
264+
if (node.root === data.safe_target) roles.push("safeTarget");
265+
if (node.root === data.head) roles.push("head");
266+
return roles;
242267
}
243268

244269
function weightRatio(node, validatorCount) {
@@ -333,13 +358,18 @@
333358
d.x += centerOffset;
334359
});
335360

336-
const flatNodes = allDescendants.map(d => ({
337-
...d.data,
338-
x: d.x,
339-
y: d.y,
340-
_color: nodeColor(d.data, data),
341-
_ratio: weightRatio(d.data, data.validator_count)
342-
}));
361+
const flatNodes = allDescendants.map(d => {
362+
const roles = nodeRoles(d.data, data);
363+
return {
364+
...d.data,
365+
x: d.x,
366+
y: d.y,
367+
_color: roles.length > 0 ? COLORS[roles[0]] : COLORS.default,
368+
_ratio: weightRatio(d.data, data.validator_count),
369+
// Colors of secondary roles, in priority order (after the primary).
370+
_haloColors: roles.slice(1).map(r => COLORS[r])
371+
};
372+
});
343373

344374
const links = [];
345375
hierarchy.links().forEach(link => {
@@ -367,17 +397,38 @@
367397
// requiring the user to move the mouse.
368398
let hoveredRoot = null;
369399

370-
function tooltipHtml(d, total) {
371-
const pct = total ? parseFloat(((d.weight / total) * 100).toFixed(2)) : 0;
372-
return `<span class="tt-label">root:</span> ${truncateRoot(d.root)}<br>` +
373-
`<span class="tt-label">slot:</span> ${d.slot}<br>` +
374-
`<span class="tt-label">proposer:</span> ${d.proposer_index}<br>` +
375-
`<span class="tt-label">weight:</span> ${d.weight}${total != null ? `/${total} (${pct}%)` : ''}`;
400+
function tooltipHtml(d, data) {
401+
const roles = nodeRoles(d, data);
402+
const lines = [
403+
`<span class="tt-label">root:</span> ${truncateRoot(d.root)}`,
404+
`<span class="tt-label">slot:</span> ${d.slot}`,
405+
`<span class="tt-label">proposer:</span> ${d.proposer_index}`,
406+
];
407+
408+
if (roles.length > 0) {
409+
const roleSpans = roles
410+
.map(r => `<span style="color: ${COLORS[r]}">${ROLE_LABELS[r]}</span>`)
411+
.join(", ");
412+
lines.push(`<span class="tt-label">status:</span> ${roleSpans}`);
413+
}
414+
415+
// Skip the weight line for purely-finalized blocks: their fork-choice
416+
// weight is 0 by design and reading "weight: 0" alongside "finalized" is
417+
// misleading. Any non-finalized role still gets a meaningful number.
418+
const isPureFinalized = roles.length === 1 && roles[0] === "finalized";
419+
if (!isPureFinalized) {
420+
const total = data.validator_count;
421+
const pct = total ? parseFloat(((d.weight / total) * 100).toFixed(2)) : 0;
422+
const suffix = total != null ? `/${total} (${pct}%)` : "";
423+
lines.push(`<span class="tt-label">weight:</span> ${d.weight}${suffix}`);
424+
}
425+
426+
return lines.join("<br>");
376427
}
377428

378-
function showTooltip(event, d) {
429+
function showTooltip(event, d, data) {
379430
hoveredRoot = d.root;
380-
tooltip.innerHTML = tooltipHtml(d, currentData?.validator_count);
431+
tooltip.innerHTML = tooltipHtml(d, data);
381432
tooltip.style.opacity = 1;
382433
tooltip.style.left = (event.clientX + 14) + "px";
383434
tooltip.style.top = (event.clientY - 14) + "px";
@@ -539,21 +590,33 @@
539590
.attr("r", NODE_RADIUS)
540591
.attr("stroke", d => d._color);
541592

542-
// Invisible hit target so hover works regardless of fill level.
593+
// Halo rings, one per secondary role. Always rendered with a transparent
594+
// stroke when no role applies so transitions can animate stroke color
595+
// smoothly when overlap appears or disappears.
596+
HALO_OFFSETS.forEach((offset, i) => {
597+
nodeEnter.append("circle")
598+
.attr("class", `halo halo-${i}`)
599+
.attr("r", NODE_RADIUS + offset)
600+
.attr("stroke", d => d._haloColors[i] || "transparent");
601+
});
602+
603+
// Invisible hit target so hover works regardless of fill level. Sized to
604+
// cover the outermost halo so the cursor still triggers the tooltip when
605+
// it sits between rings.
543606
nodeEnter.append("circle")
544607
.attr("class", "node-hit")
545-
.attr("r", NODE_RADIUS);
608+
.attr("r", NODE_RADIUS + MAX_HALO_OFFSET);
546609

547610
nodeEnter.append("text")
548611
.attr("class", "node-label")
549-
.attr("dy", NODE_RADIUS + 14)
612+
.attr("dy", LABEL_DY)
550613
.text(d => truncateRoot(d.root));
551614

552615
const nodeMerged = nodeEnter.merge(nodeGroups);
553616

554617
nodeMerged
555-
.on("mouseover", function (event, d) { showTooltip(event, d); })
556-
.on("mousemove", function (event, d) { showTooltip(event, d); })
618+
.on("mouseover", function (event, d) { showTooltip(event, d, data); })
619+
.on("mousemove", function (event, d) { showTooltip(event, d, data); })
557620
.on("mouseout", hideTooltip);
558621

559622
nodeMerged
@@ -580,13 +643,21 @@
580643
.duration(100)
581644
.attr("stroke", d => d._color);
582645

646+
HALO_OFFSETS.forEach((_, i) => {
647+
nodeMerged.select(`.halo-${i}`)
648+
.transition()
649+
.delay(TRANSITION_DURATION)
650+
.duration(100)
651+
.attr("stroke", d => d._haloColors[i] || "transparent");
652+
});
653+
583654
nodeMerged.select("text")
584655
.text(d => truncateRoot(d.root));
585656

586657
// Keep the tooltip live while the user holds the mouse still over a node.
587658
if (hoveredRoot) {
588659
const hovered = layout.nodes.find(n => n.root === hoveredRoot);
589-
if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data.validator_count);
660+
if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data);
590661
}
591662

592663
// Auto-scroll to head node if head tracking is enabled.

0 commit comments

Comments
 (0)