|
51 | 51 | gap: 2px; |
52 | 52 | } |
53 | 53 |
|
| 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 | + |
54 | 76 | .value-head { color: #ff9800; } |
55 | 77 | .value-justified { color: #2196f3; } |
56 | 78 | .value-finalized { color: #4caf50; } |
|
157 | 179 | <span class="label">Validators</span> |
158 | 180 | <span class="value value-validators" id="validator-count">--</span> |
159 | 181 | </div> |
| 182 | + <button id="follow-head-toggle" type="button" aria-pressed="true">Following Head</button> |
160 | 183 | </div> |
161 | 184 |
|
162 | 185 | <div id="chart-container"> |
|
185 | 208 | const container = document.getElementById("chart-container"); |
186 | 209 | const tooltip = document.getElementById("tooltip"); |
187 | 210 | const emptyMsg = document.getElementById("empty-message"); |
| 211 | + const followHeadToggle = document.getElementById("follow-head-toggle"); |
188 | 212 |
|
189 | 213 | let svg, gLinks, gNodes, gAxis; |
190 | 214 | let currentData = null; |
| 215 | + let followHead = true; |
| 216 | + let currentHeadScrollTarget = null; |
| 217 | + let lastScrollTop = 0; |
| 218 | + let programmaticScrollTimeout = null; |
| 219 | + let isProgrammaticScroll = false; |
191 | 220 |
|
192 | 221 | function initSVG() { |
193 | 222 | svg = d3.select("#chart-container") |
|
359 | 388 | tooltip.style.opacity = 0; |
360 | 389 | } |
361 | 390 |
|
| 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 | + |
362 | 435 | function render(data) { |
363 | 436 | if (!data || !data.nodes || data.nodes.length === 0) { |
364 | 437 | emptyMsg.style.display = "block"; |
|
516 | 589 | if (hovered) tooltip.innerHTML = tooltipHtml(hovered, data.validator_count); |
517 | 590 | } |
518 | 591 |
|
519 | | - // Auto-scroll to head node |
| 592 | + // Auto-scroll to head node if head tracking is enabled. |
520 | 593 | const headNode = layout.nodes.find(n => n.root === data.head); |
521 | 594 | if (headNode) { |
522 | 595 | const containerRect = container.getBoundingClientRect(); |
523 | 596 | 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; |
529 | 608 | } |
530 | 609 | } |
531 | 610 |
|
|
547 | 626 | } |
548 | 627 |
|
549 | 628 | initSVG(); |
| 629 | + updateFollowHeadToggle(); |
550 | 630 | fetchAndRender(); |
551 | 631 | setInterval(fetchAndRender, POLL_INTERVAL); |
552 | 632 |
|
| 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 | + |
553 | 660 | window.addEventListener("resize", () => { |
554 | 661 | if (currentData) render(currentData); |
555 | 662 | }); |
|
0 commit comments