Skip to content

Commit d76f370

Browse files
committed
Add follow head toggle to fork choice viz
1 parent 634e1c8 commit d76f370

1 file changed

Lines changed: 113 additions & 6 deletions

File tree

crates/net/rpc/static/fork_choice.html

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,28 @@
5151
gap: 2px;
5252
}
5353

54+
#follow-head-toggle {
55+
margin-left: auto;
56+
padding: 7px 12px;
57+
border: 1px solid #ff9800;
58+
border-radius: 4px;
59+
background: transparent;
60+
color: #ff9800;
61+
font-family: "Courier New", Courier, monospace;
62+
font-size: 12px;
63+
font-weight: bold;
64+
cursor: pointer;
65+
}
66+
67+
#follow-head-toggle:hover {
68+
background: rgba(255, 152, 0, 0.12);
69+
}
70+
71+
#follow-head-toggle[aria-pressed="false"] {
72+
border-color: #666;
73+
color: #aaa;
74+
}
75+
5476
.value-head { color: #ff9800; }
5577
.value-justified { color: #2196f3; }
5678
.value-finalized { color: #4caf50; }
@@ -157,6 +179,7 @@
157179
<span class="label">Validators</span>
158180
<span class="value value-validators" id="validator-count">--</span>
159181
</div>
182+
<button id="follow-head-toggle" type="button" aria-pressed="true">Following Head</button>
160183
</div>
161184

162185
<div id="chart-container">
@@ -185,9 +208,15 @@
185208
const container = document.getElementById("chart-container");
186209
const tooltip = document.getElementById("tooltip");
187210
const emptyMsg = document.getElementById("empty-message");
211+
const followHeadToggle = document.getElementById("follow-head-toggle");
188212

189213
let svg, gLinks, gNodes, gAxis;
190214
let currentData = null;
215+
let followHead = true;
216+
let currentHeadScrollTarget = null;
217+
let lastScrollTop = 0;
218+
let programmaticScrollTimeout = null;
219+
let isProgrammaticScroll = false;
191220

192221
function initSVG() {
193222
svg = d3.select("#chart-container")
@@ -359,6 +388,50 @@
359388
tooltip.style.opacity = 0;
360389
}
361390

391+
function updateFollowHeadToggle() {
392+
followHeadToggle.setAttribute("aria-pressed", String(followHead));
393+
followHeadToggle.textContent = followHead ? "Following Head" : "Follow Head";
394+
}
395+
396+
function markProgrammaticScroll() {
397+
isProgrammaticScroll = true;
398+
if (programmaticScrollTimeout) clearTimeout(programmaticScrollTimeout);
399+
programmaticScrollTimeout = setTimeout(() => {
400+
isProgrammaticScroll = false;
401+
}, TRANSITION_DURATION + 150);
402+
}
403+
404+
function scrollToHead(behavior = "smooth") {
405+
if (currentHeadScrollTarget == null) return;
406+
markProgrammaticScroll();
407+
container.scrollTo({
408+
top: currentHeadScrollTarget,
409+
behavior
410+
});
411+
}
412+
413+
function isAtHead() {
414+
return currentHeadScrollTarget != null &&
415+
Math.abs(container.scrollTop - currentHeadScrollTarget) <= NODE_RADIUS * 3;
416+
}
417+
418+
function setFollowHead(enabled, shouldScroll = false) {
419+
followHead = enabled;
420+
updateFollowHeadToggle();
421+
422+
if (followHead && shouldScroll) {
423+
scrollToHead();
424+
}
425+
}
426+
427+
function markUserScrollIntent() {
428+
isProgrammaticScroll = false;
429+
if (programmaticScrollTimeout) {
430+
clearTimeout(programmaticScrollTimeout);
431+
programmaticScrollTimeout = null;
432+
}
433+
}
434+
362435
function render(data) {
363436
if (!data || !data.nodes || data.nodes.length === 0) {
364437
emptyMsg.style.display = "block";
@@ -516,16 +589,22 @@
516589
if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data.validator_count);
517590
}
518591

519-
// Auto-scroll to head node
592+
// Auto-scroll to head node if head tracking is enabled.
520593
const headNode = layout.nodes.find(n => n.root === data.head);
521594
if (headNode) {
522595
const containerRect = container.getBoundingClientRect();
523596
const headY = headNode.y;
524-
const scrollTarget = headY - containerRect.height / 2;
525-
container.scrollTo({
526-
top: Math.max(0, scrollTarget),
527-
behavior: "smooth"
528-
});
597+
const maxScrollTop = Math.max(0, layout.height - containerRect.height);
598+
currentHeadScrollTarget = Math.min(
599+
maxScrollTop,
600+
Math.max(0, headY - containerRect.height / 2)
601+
);
602+
603+
if (followHead) {
604+
scrollToHead();
605+
}
606+
} else {
607+
currentHeadScrollTarget = null;
529608
}
530609
}
531610

@@ -547,9 +626,37 @@
547626
}
548627

549628
initSVG();
629+
updateFollowHeadToggle();
550630
fetchAndRender();
551631
setInterval(fetchAndRender, POLL_INTERVAL);
552632

633+
followHeadToggle.addEventListener("click", () => {
634+
setFollowHead(!followHead, true);
635+
});
636+
637+
container.addEventListener("scroll", () => {
638+
const scrollingUp = container.scrollTop < lastScrollTop - 2;
639+
640+
if (!isProgrammaticScroll) {
641+
if (followHead && scrollingUp && !isAtHead()) {
642+
setFollowHead(false);
643+
} else if (!followHead && isAtHead()) {
644+
setFollowHead(true);
645+
}
646+
}
647+
648+
lastScrollTop = container.scrollTop;
649+
});
650+
651+
container.addEventListener("wheel", markUserScrollIntent, { passive: true });
652+
container.addEventListener("pointerdown", markUserScrollIntent);
653+
container.addEventListener("touchstart", markUserScrollIntent, { passive: true });
654+
window.addEventListener("keydown", event => {
655+
if (["ArrowUp", "PageUp", "Home"].includes(event.key)) {
656+
markUserScrollIntent();
657+
}
658+
});
659+
553660
window.addEventListener("resize", () => {
554661
if (currentData) render(currentData);
555662
});

0 commit comments

Comments
 (0)