Skip to content

fix(forkchoice-viz): expose all role overlaps per block #341

Open
conache wants to merge 2 commits intolambdaclass:mainfrom
conache:forkchoice-viz-handle-overlapping-roles
Open

fix(forkchoice-viz): expose all role overlaps per block #341
conache wants to merge 2 commits intolambdaclass:mainfrom
conache:forkchoice-viz-handle-overlapping-roles

Conversation

@conache
Copy link
Copy Markdown
Contributor

@conache conache commented May 1, 2026

🗒️ 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:
Screenshot 2026-05-01 at 16 43 16

Screenshot 2026-05-01 at 17 10 44 Screenshot 2026-05-01 at 17 10 51

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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 1, 2026

Greptile Summary

This PR reworks the fork-choice tree visualizer so that every role a block holds (finalized, justified, safe target, head) is shown simultaneously — the innermost filled circle carries the strongest role's color, and extra concentric halo rings display secondary roles. The tooltip's status: line lists all roles in their respective colors.

Confidence Score: 4/5

Safe to merge — visualization-only change with no consensus or weight-computation impact; three P2 polish issues found.

No P0/P1 issues found. Three P2 findings: halo ring area not interactive (hit-target radius not extended), redundant nodeRoles calls per node, and missing transitions on halo stroke updates. None affect correctness.

crates/net/rpc/static/fork_choice.html — hit-target radius and halo transition consistency.

Important Files Changed

Filename Overview
crates/net/rpc/static/fork_choice.html Adds concentric halo rings and multi-role tooltips to the fork-choice visualizer; halo hit-target radius not extended to match outermost ring, and halo stroke updates lack the transition applied to other node colors.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[poll: fetch /fork_choice_data] --> B[render data]
    B --> C[nodeRoles node, data]
    C --> D{roles found?}
    D -- "roles[0] = primary" --> E[_color = COLORS primary]
    D -- "roles[1..] = secondary" --> F[_haloColors array]
    D -- "empty" --> G[_color = COLORS.default]
    E & F & G --> H[D3 data join layout.nodes]
    H --> I[nodeEnter: append circles]
    I --> I1[node-inner filled circle]
    I --> I2[node-outer ring]
    I --> I3[halo-0 / halo-1 / halo-2 rings]
    I --> I4[node-hit transparent r=NODE_RADIUS]
    H --> J[nodeMerged: update existing]
    J --> J1[transition node-inner fill]
    J --> J2[transition node-outer stroke]
    J --> J3[instant halo stroke update]
    J --> J4[re-bind mouseover/mousemove with current data]
    J4 --> K[showTooltip → tooltipHtml d, data]
    K --> L[nodeRoles d, data again]
    L --> M[status: colored role spans]
    L --> N{isPureFinalized?}
    N -- no --> O[show weight line]
    N -- yes --> P[hide weight line]
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/net/rpc/static/fork_choice.html:81-85
**Halo ring area is not hoverable**

The halo circles have `pointer-events: none`, and the `node-hit` circle's radius stays at `NODE_RADIUS` — not extended to cover the outermost halo at `NODE_RADIUS + 18`. Any cursor position strictly within a halo ring (between `NODE_RADIUS` and `NODE_RADIUS + 18`) falls outside every pointer-receiving element, so the tooltip won't appear and the cursor won't show as `pointer` there. Updating the hit target's radius to match the outermost halo would fix this — in the JS where `node-hit` is appended:
```js
.attr("r", NODE_RADIUS + MAX_HALO_OFFSET);
```

### Issue 2 of 3
crates/net/rpc/static/fork_choice.html:340-350
**`nodeRoles` called twice per node during layout**

Inside the `flatNodes` map, `roles` is already computed via `nodeRoles(d.data, data)`, but the call to `nodeColor(d.data, data)` on the next property invokes `nodeRoles` a second time internally. The primary color can be derived directly from the already-computed array:
```js
_color: roles.length > 0 ? COLORS[roles[0]] : COLORS.default,
```

### Issue 3 of 3
crates/net/rpc/static/fork_choice.html:576-579
**Halo strokes update without a transition**

`node-inner` and `node-outer` both use `.transition().delay(TRANSITION_DURATION).duration(100)` when their color changes, but the halo update here calls `.attr(...)` directly with no transition. When a block gains or loses a role between polls, the halo color flips instantaneously while the primary ring animates. Adding the same transition keeps them in sync:
```js
nodeMerged.select(`.halo-${i}`)
  .transition()
  .delay(TRANSITION_DURATION)
  .duration(100)
  .attr("stroke", d => d._haloColors[i] || "transparent");
```

Reviews (1): Last reviewed commit: "Add halo nodes indicating overlapping no..." | Re-trigger Greptile

Comment on lines +81 to +85
.halo {
fill: none;
stroke-width: 2;
pointer-events: none;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Halo ring area is not hoverable

The halo circles have pointer-events: none, and the node-hit circle's radius stays at NODE_RADIUS — not extended to cover the outermost halo at NODE_RADIUS + 18. Any cursor position strictly within a halo ring (between NODE_RADIUS and NODE_RADIUS + 18) falls outside every pointer-receiving element, so the tooltip won't appear and the cursor won't show as pointer there. Updating the hit target's radius to match the outermost halo would fix this — in the JS where node-hit is appended:

.attr("r", NODE_RADIUS + MAX_HALO_OFFSET);
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/static/fork_choice.html
Line: 81-85

Comment:
**Halo ring area is not hoverable**

The halo circles have `pointer-events: none`, and the `node-hit` circle's radius stays at `NODE_RADIUS` — not extended to cover the outermost halo at `NODE_RADIUS + 18`. Any cursor position strictly within a halo ring (between `NODE_RADIUS` and `NODE_RADIUS + 18`) falls outside every pointer-receiving element, so the tooltip won't appear and the cursor won't show as `pointer` there. Updating the hit target's radius to match the outermost halo would fix this — in the JS where `node-hit` is appended:
```js
.attr("r", NODE_RADIUS + MAX_HALO_OFFSET);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +340 to 350
...d.data,
x: d.x,
y: d.y,
_color: nodeColor(d.data, data),
_ratio: weightRatio(d.data, data.validator_count),
// Colors of secondary roles, in priority order (after the primary).
_haloColors: roles.slice(1).map(r => COLORS[r])
};
});

const links = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 nodeRoles called twice per node during layout

Inside the flatNodes map, roles is already computed via nodeRoles(d.data, data), but the call to nodeColor(d.data, data) on the next property invokes nodeRoles a second time internally. The primary color can be derived directly from the already-computed array:

_color: roles.length > 0 ? COLORS[roles[0]] : COLORS.default,
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/static/fork_choice.html
Line: 340-350

Comment:
**`nodeRoles` called twice per node during layout**

Inside the `flatNodes` map, `roles` is already computed via `nodeRoles(d.data, data)`, but the call to `nodeColor(d.data, data)` on the next property invokes `nodeRoles` a second time internally. The primary color can be derived directly from the already-computed array:
```js
_color: roles.length > 0 ? COLORS[roles[0]] : COLORS.default,
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +576 to +579
HALO_OFFSETS.forEach((_, i) => {
nodeMerged.select(`.halo-${i}`)
.attr("stroke", d => d._haloColors[i] || "transparent");
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Halo strokes update without a transition

node-inner and node-outer both use .transition().delay(TRANSITION_DURATION).duration(100) when their color changes, but the halo update here calls .attr(...) directly with no transition. When a block gains or loses a role between polls, the halo color flips instantaneously while the primary ring animates. Adding the same transition keeps them in sync:

nodeMerged.select(`.halo-${i}`)
  .transition()
  .delay(TRANSITION_DURATION)
  .duration(100)
  .attr("stroke", d => d._haloColors[i] || "transparent");
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/static/fork_choice.html
Line: 576-579

Comment:
**Halo strokes update without a transition**

`node-inner` and `node-outer` both use `.transition().delay(TRANSITION_DURATION).duration(100)` when their color changes, but the halo update here calls `.attr(...)` directly with no transition. When a block gains or loses a role between polls, the halo color flips instantaneously while the primary ring animates. Adding the same transition keeps them in sync:
```js
nodeMerged.select(`.halo-${i}`)
  .transition()
  .delay(TRANSITION_DURATION)
  .duration(100)
  .attr("stroke", d => d._haloColors[i] || "transparent");
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fork choice viz: show overlapping safe target and head

1 participant