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