diff --git a/crates/net/rpc/static/fork_choice.html b/crates/net/rpc/static/fork_choice.html
index 5a49d96..9a47235 100644
--- a/crates/net/rpc/static/fork_choice.html
+++ b/crates/net/rpc/static/fork_choice.html
@@ -78,6 +78,12 @@
cursor: pointer;
}
+ .halo {
+ fill: none;
+ stroke-width: 2;
+ pointer-events: none;
+ }
+
.node-inner {
stroke: none;
pointer-events: none;
@@ -182,6 +188,21 @@
default: "#666"
};
+ const ROLE_LABELS = {
+ finalized: "finalized",
+ justified: "justified",
+ safeTarget: "safe target",
+ head: "head"
+ };
+
+ // Concentric halos drawn outside the primary ring, one per secondary role.
+ const HALO_OFFSETS = [6, 12, 18];
+ const MAX_HALO_OFFSET = HALO_OFFSETS[HALO_OFFSETS.length - 1];
+ // Reserve label space below the outermost halo so the gap between the
+ // outermost circle and the label stays constant across all nodes,
+ // regardless of how many halos are drawn.
+ const LABEL_DY = NODE_RADIUS + MAX_HALO_OFFSET + 14;
+
const container = document.getElementById("chart-container");
const tooltip = document.getElementById("tooltip");
const emptyMsg = document.getElementById("empty-message");
@@ -204,12 +225,16 @@
return root.length > 10 ? root.slice(0, 10) : root;
}
- function nodeColor(node, data) {
- if (node.root === data.head) return COLORS.head;
- if (node.root === data.safe_target) return COLORS.safeTarget;
- if (node.root === data.justified.root) return COLORS.justified;
- if (node.slot <= data.finalized.slot) return COLORS.finalized;
- return COLORS.default;
+ // Returns the roles that apply to a block, in natural priority order
+ // (strongest commitment first). The first entry drives the primary color;
+ // the rest become halos around it.
+ function nodeRoles(node, data) {
+ const roles = [];
+ if (node.slot <= data.finalized.slot) roles.push("finalized");
+ if (node.root === data.justified.root) roles.push("justified");
+ if (node.root === data.safe_target) roles.push("safeTarget");
+ if (node.root === data.head) roles.push("head");
+ return roles;
}
function weightRatio(node, validatorCount) {
@@ -304,13 +329,18 @@
d.x += centerOffset;
});
- const flatNodes = allDescendants.map(d => ({
- ...d.data,
- x: d.x,
- y: d.y,
- _color: nodeColor(d.data, data),
- _ratio: weightRatio(d.data, data.validator_count)
- }));
+ const flatNodes = allDescendants.map(d => {
+ const roles = nodeRoles(d.data, data);
+ return {
+ ...d.data,
+ x: d.x,
+ y: d.y,
+ _color: roles.length > 0 ? COLORS[roles[0]] : COLORS.default,
+ _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 = [];
hierarchy.links().forEach(link => {
@@ -338,17 +368,38 @@
// requiring the user to move the mouse.
let hoveredRoot = null;
- function tooltipHtml(d, total) {
- const pct = total ? parseFloat(((d.weight / total) * 100).toFixed(2)) : 0;
- return `root: ${truncateRoot(d.root)}
` +
- `slot: ${d.slot}
` +
- `proposer: ${d.proposer_index}
` +
- `weight: ${d.weight}${total != null ? `/${total} (${pct}%)` : ''}`;
+ function tooltipHtml(d, data) {
+ const roles = nodeRoles(d, data);
+ const lines = [
+ `root: ${truncateRoot(d.root)}`,
+ `slot: ${d.slot}`,
+ `proposer: ${d.proposer_index}`,
+ ];
+
+ if (roles.length > 0) {
+ const roleSpans = roles
+ .map(r => `${ROLE_LABELS[r]}`)
+ .join(", ");
+ lines.push(`status: ${roleSpans}`);
+ }
+
+ // Skip the weight line for purely-finalized blocks: their fork-choice
+ // weight is 0 by design and reading "weight: 0" alongside "finalized" is
+ // misleading. Any non-finalized role still gets a meaningful number.
+ const isPureFinalized = roles.length === 1 && roles[0] === "finalized";
+ if (!isPureFinalized) {
+ const total = data.validator_count;
+ const pct = total ? parseFloat(((d.weight / total) * 100).toFixed(2)) : 0;
+ const suffix = total != null ? `/${total} (${pct}%)` : "";
+ lines.push(`weight: ${d.weight}${suffix}`);
+ }
+
+ return lines.join("
");
}
- function showTooltip(event, d) {
+ function showTooltip(event, d, data) {
hoveredRoot = d.root;
- tooltip.innerHTML = tooltipHtml(d, currentData?.validator_count);
+ tooltip.innerHTML = tooltipHtml(d, data);
tooltip.style.opacity = 1;
tooltip.style.left = (event.clientX + 14) + "px";
tooltip.style.top = (event.clientY - 14) + "px";
@@ -466,21 +517,33 @@
.attr("r", NODE_RADIUS)
.attr("stroke", d => d._color);
- // Invisible hit target so hover works regardless of fill level.
+ // Halo rings, one per secondary role. Always rendered with a transparent
+ // stroke when no role applies so transitions can animate stroke color
+ // smoothly when overlap appears or disappears.
+ HALO_OFFSETS.forEach((offset, i) => {
+ nodeEnter.append("circle")
+ .attr("class", `halo halo-${i}`)
+ .attr("r", NODE_RADIUS + offset)
+ .attr("stroke", d => d._haloColors[i] || "transparent");
+ });
+
+ // Invisible hit target so hover works regardless of fill level. Sized to
+ // cover the outermost halo so the cursor still triggers the tooltip when
+ // it sits between rings.
nodeEnter.append("circle")
.attr("class", "node-hit")
- .attr("r", NODE_RADIUS);
+ .attr("r", NODE_RADIUS + MAX_HALO_OFFSET);
nodeEnter.append("text")
.attr("class", "node-label")
- .attr("dy", NODE_RADIUS + 14)
+ .attr("dy", LABEL_DY)
.text(d => truncateRoot(d.root));
const nodeMerged = nodeEnter.merge(nodeGroups);
nodeMerged
- .on("mouseover", function (event, d) { showTooltip(event, d); })
- .on("mousemove", function (event, d) { showTooltip(event, d); })
+ .on("mouseover", function (event, d) { showTooltip(event, d, data); })
+ .on("mousemove", function (event, d) { showTooltip(event, d, data); })
.on("mouseout", hideTooltip);
nodeMerged
@@ -507,13 +570,21 @@
.duration(100)
.attr("stroke", d => d._color);
+ HALO_OFFSETS.forEach((_, i) => {
+ nodeMerged.select(`.halo-${i}`)
+ .transition()
+ .delay(TRANSITION_DURATION)
+ .duration(100)
+ .attr("stroke", d => d._haloColors[i] || "transparent");
+ });
+
nodeMerged.select("text")
.text(d => truncateRoot(d.root));
// Keep the tooltip live while the user holds the mouse still over a node.
if (hoveredRoot) {
const hovered = layout.nodes.find(n => n.root === hoveredRoot);
- if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data.validator_count);
+ if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data);
}
// Auto-scroll to head node