Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 96 additions & 33 deletions crates/net/rpc/static/fork_choice.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,29 @@
stroke-width: 1.5;
}

.node-circle {
.node-outer {
fill: none;
stroke-width: 2;
cursor: pointer;
}

.node-inner {
stroke: none;
pointer-events: none;
}

.node-hit {
fill: transparent;
stroke: none;
cursor: pointer;
}

.fill-mask {
fill: #1a1a2e;
stroke: none;
pointer-events: none;
}

.node-label {
fill: #ccc;
font-size: 10px;
Expand Down Expand Up @@ -149,10 +167,11 @@

<script>
const POLL_INTERVAL = 2000;
const MARGIN = { top: 40, right: 60, bottom: 40, left: 80 };
const SLOT_HEIGHT = 50;
const MIN_RADIUS = 8;
const MAX_RADIUS = 30;
const MARGIN = { top: 40, right: 60, bottom: 140, left: 80 };
const SLOT_HEIGHT = 120;
const MAX_SLOT_HEIGHT = 200;
const NODE_RADIUS = 16;
const INNER_RADIUS = NODE_RADIUS - 3;
const TRANSITION_DURATION = 500;

const COLORS = {
Expand Down Expand Up @@ -193,15 +212,9 @@
return COLORS.default;
}

function nodeStroke(node, data) {
const color = nodeColor(node, data);
return d3.color(color).darker(0.5).toString();
}

function nodeRadius(node, validatorCount) {
if (!validatorCount || validatorCount === 0) return MIN_RADIUS;
const ratio = node.weight / validatorCount;
return MIN_RADIUS + ratio * (MAX_RADIUS - MIN_RADIUS);
function weightRatio(node, validatorCount) {
if (!validatorCount) return 0;
return Math.max(0, Math.min(1, node.weight / validatorCount));
}

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

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

allDescendants.forEach(d => {
d.y = MARGIN.top + ((d.data.slot - minSlot) / slotRange) * totalHeight;
Expand All @@ -291,8 +309,7 @@
x: d.x,
y: d.y,
_color: nodeColor(d.data, data),
_stroke: nodeStroke(d.data, data),
_radius: nodeRadius(d.data, data.validator_count)
_ratio: weightRatio(d.data, data.validator_count)
}));

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

function showTooltip(event, d) {
tooltip.innerHTML =
`<span class="tt-label">root:</span> ${truncateRoot(d.root)}<br>` +
// Tracked so render() can refresh the tooltip on each poll without
// 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;
Comment thread
MegaRedHand marked this conversation as resolved.
return `<span class="tt-label">root:</span> ${truncateRoot(d.root)}<br>` +
`<span class="tt-label">slot:</span> ${d.slot}<br>` +
`<span class="tt-label">proposer:</span> ${d.proposer_index}<br>` +
`<span class="tt-label">weight:</span> ${d.weight}`;
`<span class="tt-label">weight:</span> ${d.weight}${total != null ? `/${total} (${pct}%)` : ''}`;
}

function showTooltip(event, d) {
hoveredRoot = d.root;
tooltip.innerHTML = tooltipHtml(d, currentData?.validator_count);
tooltip.style.opacity = 1;
tooltip.style.left = (event.clientX + 14) + "px";
tooltip.style.top = (event.clientY - 14) + "px";
}

function hideTooltip() {
hoveredRoot = null;
tooltip.style.opacity = 0;
}

Expand Down Expand Up @@ -391,15 +418,19 @@
const linksEnter = links.enter()
.append("line")
.attr("class", "link")
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y + NODE_RADIUS)
.attr("x2", d => d.source.x)
.attr("y2", d => d.source.y + NODE_RADIUS)
.attr("opacity", 0);

links.merge(linksEnter)
.transition()
.duration(TRANSITION_DURATION)
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("y1", d => d.source.y + NODE_RADIUS)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y)
.attr("y2", d => d.target.y - NODE_RADIUS)
.attr("opacity", 1);

// Nodes
Expand All @@ -419,14 +450,30 @@
.attr("opacity", 0);

nodeEnter.append("circle")
.attr("class", "node-circle")
.attr("r", d => d._radius)
.attr("fill", d => d._color)
.attr("stroke", d => d._stroke);
.attr("class", "node-inner")
.attr("r", INNER_RADIUS)
.attr("fill", d => d._color);

nodeEnter.append("rect")
.attr("class", "fill-mask")
.attr("x", -INNER_RADIUS)
.attr("width", INNER_RADIUS * 2)
.attr("y", -INNER_RADIUS)
.attr("height", d => (1 - d._ratio) * INNER_RADIUS * 2);
Comment on lines +457 to +462
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 fill-mask rect corners protrude outside the outer ring

The mask rect spans ±INNER_RADIUS (±13 px), so its corners sit at distance √(13²+13²) ≈ 18.4 px from center — outside NODE_RADIUS (16 px) by ~2.4 px. This is invisible today because .fill-mask fill (#1a1a2e) matches the page background, but it is a fragile paint-over rather than a true clip. Any theme change or scrollable container with a different background will expose small dark squares at the diagonal corners of each partially-filled node.

Using an SVG <clipPath> with a circle of radius INNER_RADIUS applied to the rect would make this robust without a visual change.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/rpc/static/fork_choice.html
Line: 457-462

Comment:
**`fill-mask` rect corners protrude outside the outer ring**

The mask rect spans `±INNER_RADIUS` (±13 px), so its corners sit at distance √(13²+13²) ≈ 18.4 px from center — outside `NODE_RADIUS` (16 px) by ~2.4 px. This is invisible today because `.fill-mask` fill (`#1a1a2e`) matches the page background, but it is a fragile paint-over rather than a true clip. Any theme change or scrollable container with a different background will expose small dark squares at the diagonal corners of each partially-filled node.

Using an SVG `<clipPath>` with a circle of radius `INNER_RADIUS` applied to the rect would make this robust without a visual change.

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


nodeEnter.append("circle")
.attr("class", "node-outer")
.attr("r", NODE_RADIUS)
.attr("stroke", d => d._color);

// Invisible hit target so hover works regardless of fill level.
nodeEnter.append("circle")
.attr("class", "node-hit")
.attr("r", NODE_RADIUS);

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

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

nodeMerged.select("circle")
// Fill animates first, then color flips once the fill has settled.
nodeMerged.select(".fill-mask")
.transition()
.duration(TRANSITION_DURATION)
.attr("r", d => d._radius)
.attr("fill", d => d._color)
.attr("stroke", d => d._stroke);
.attr("height", d => (1 - d._ratio) * INNER_RADIUS * 2);

nodeMerged.select(".node-inner")
.transition()
.delay(TRANSITION_DURATION)
.duration(100)
.attr("fill", d => d._color);

nodeMerged.select(".node-outer")
.transition()
.delay(TRANSITION_DURATION)
.duration(100)
.attr("stroke", d => d._color);

nodeMerged.select("text")
.attr("dy", d => d._radius + 14)
.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);
}

// Auto-scroll to head node
const headNode = layout.nodes.find(n => n.root === data.head);
if (headNode) {
Expand Down
Loading