import { brk } from "../../utils/client.js"; import { createEnteringConfirmedCube, createPlaceholderCube, createProjectedCube, setConfirmedInterval, updateProjectedCube, updateProjectedTime, } from "./block-cube.js"; import { createEdgeButton } from "./edge.js"; import { distanceFromViewport, findVisibleConfirmedHeight, isHorizontalLayout, olderRemaining, olderRunway, olderWheelDelta, preserveScrollPosition, scrollToElement, } from "./scroll.js"; import { transitionMs } from "./transition.js"; const BLOCK_BATCH_SIZE = 15; const EDGE_LOAD_DISTANCE = 50; const OLDER_RESERVE_VIEWPORTS = 6; const POLL_INTERVAL = 1_000; const PROJECTED_LIMIT = 8; const TARGET_BLOCK_SECONDS = 600; const TIP_BLOCK_THRESHOLD = 10; /** @typedef {Awaited>[number]} Block */ /** @typedef {Awaited>[number]} MempoolBlock */ /** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */ /** * @param {{ onSelect?: (block: Block) => void }} [options] */ export function createChain({ onSelect = () => {} } = {}) { const element = document.createElement("div"); const scrollElement = document.createElement("div"); const blocksElement = document.createElement("div"); const tipButton = createEdgeButton("tip", "↑", "←", "Jump to chain tip", () => { jumpToTip(); }); element.id = "chain"; setTipVisible(false); scrollElement.dataset.chainScroll = ""; blocksElement.dataset.chainBlocks = ""; scrollElement.append(blocksElement); element.append(tipButton, scrollElement); /** @type {HTMLButtonElement | null} */ let selectedCube = null; /** @type {HTMLButtonElement | null} */ let tipCube = null; /** @type {Map} */ const blocksByHash = new Map(); /** @type {ReturnType[]} */ const projectedCubes = []; let active = false; let newestHeight = -1; let oldestHeight = Infinity; let oldestReservedHeight = -1; let newestTimestamp = 0; let hydratingOlder = false; let loadingNewer = false; let polling = false; let reachedTip = false; let olderGeneration = 0; /** @type {OlderBatch[]} */ const olderBatches = []; /** @type {number | undefined} */ let pollId; /** @type {number | undefined} */ let jumpTimeout; let tipSyncFrame = 0; let jumping = false; /** @type {AbortController} */ let controller = new AbortController(); /** @param {string | number | null | undefined} hashOrHeight */ function findCube(hashOrHeight) { if (hashOrHeight == null) { return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null; } const attribute = typeof hashOrHeight === "number" ? "height" : "hash"; return /** @type {HTMLButtonElement | null} */ ( blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`) ); } function firstProjectedCube() { return projectedCubes[0]?.element ?? null; } function newestConfirmedCube() { const firstProjected = firstProjectedCube(); return /** @type {HTMLButtonElement | null} */ ( firstProjected ? firstProjected.previousElementSibling : blocksElement.lastElementChild ); } function deselectCube() { if (selectedCube) delete selectedCube.dataset.selected; selectedCube = null; } function updateTipCube() { tipCube?.removeAttribute("data-tip"); tipCube = newestConfirmedCube(); tipCube?.setAttribute("data-tip", ""); } function jumpToTip() { if (!tipCube || jumping) return; jumping = true; element.dataset.jumping = ""; element.addEventListener("transitionend", finishJumpToTip); jumpTimeout = window.setTimeout( finishJumpToTip, transitionMs(element, "opacity") + 50, ); } /** @param {Event} [event] */ function finishJumpToTip(event) { if ( event instanceof TransitionEvent && (event.target !== element || event.propertyName !== "opacity") ) { return; } if (tipCube) selectCube(tipCube, { scroll: "instant" }); cancelJump(); } function cancelJump() { if (jumpTimeout !== undefined) { window.clearTimeout(jumpTimeout); jumpTimeout = undefined; } element.removeEventListener("transitionend", finishJumpToTip); delete element.dataset.jumping; jumping = false; } /** * @param {HTMLButtonElement} cube * @param {{ scroll?: "smooth" | "instant" }} [options] */ function selectCube(cube, { scroll } = {}) { if (cube !== selectedCube) { deselectCube(); selectedCube = cube; cube.dataset.selected = ""; } const hash = cube.dataset.hash; const block = hash ? blocksByHash.get(hash) : undefined; if (block) onSelect(block); if (scroll) { scrollToElement(cube, scroll); scheduleTipVisibilitySync(); } } function isHorizontal() { return isHorizontalLayout(blocksElement); } /** @param {number} [delta] */ function reserveOlderRunway(delta = 0) { if (!active || oldestReservedHeight <= 0) return; const horizontal = isHorizontal(); const runway = olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta; let remaining = olderRemaining(scrollElement, horizontal); while (remaining < runway) { if (!reserveOlderBatch()) return; remaining = olderRemaining(scrollElement, horizontal); } } function clear() { newestHeight = -1; oldestHeight = Infinity; oldestReservedHeight = -1; newestTimestamp = 0; hydratingOlder = false; loadingNewer = false; reachedTip = false; olderGeneration++; selectedCube = null; tipCube = null; blocksByHash.clear(); blocksElement.textContent = ""; projectedCubes.length = 0; olderBatches.length = 0; setTipVisible(false); } /** * @param {Element | null} anchor * @param {number} count */ function prependOlderPlaceholders(anchor, count) { const fragment = document.createDocumentFragment(); const placeholders = /** @type {HTMLElement[]} */ ([]); for (let i = 0; i < count; i++) { const cube = createPlaceholderCube(); placeholders.push(cube); fragment.append(cube); } blocksElement.insertBefore(fragment, anchor); return placeholders; } function reserveOlderBatch() { if (!active || oldestReservedHeight <= 0) return false; const anchor = blocksElement.firstElementChild; const count = Math.min(BLOCK_BATCH_SIZE, oldestReservedHeight); const startHeight = oldestReservedHeight - 1; const placeholders = prependOlderPlaceholders(anchor, count); if (!placeholders.length) return false; oldestReservedHeight -= placeholders.length; olderBatches.push({ generation: olderGeneration, startHeight, placeholders }); void hydrateOlderBatches(); return true; } /** @param {Block} block */ function createKnownEnteringConfirmedCube(block) { blocksByHash.set(block.id, block); return createEnteringConfirmedCube(block, selectCube); } /** @param {Block[]} blocks */ function appendNewerBlocks(blocks) { if (!blocks.length) return false; const anchor = newestConfirmedCube(); const anchorRect = anchor?.getBoundingClientRect(); for (let i = blocks.length - 1; i >= 0; i--) { const block = blocks[i]; if (block.height > newestHeight) { appendConfirmed(createKnownEnteringConfirmedCube(block)); } else { blocksByHash.set(block.id, block); } } newestHeight = Math.max(newestHeight, blocks[0].height); newestTimestamp = blocks[0].timestamp; updateTipCube(); refreshProjected(); preserveScrollPosition(scrollElement, anchor, anchorRect); syncTipVisibility(); return true; } /** @param {number | null} [height] */ async function loadInitial(height) { const blocks = height != null ? await brk.getBlocksV1FromHeight(height, { signal: controller.signal }) : await brk.getBlocksV1({ signal: controller.signal }); clear(); for (const block of blocks) { prependConfirmed(createKnownEnteringConfirmedCube(block)); } newestHeight = blocks[0].height; oldestHeight = blocks[blocks.length - 1].height; oldestReservedHeight = oldestHeight; newestTimestamp = blocks[0].timestamp; reachedTip = height == null; updateTipCube(); reserveOlderRunway(); if (reachedTip) await pollProjected(); else await loadNewer(); return blocks[0].id; } /** @param {string | number | null | undefined} hashOrHeight */ async function resolveHeight(hashOrHeight) { if (typeof hashOrHeight === "number") return hashOrHeight; if (typeof hashOrHeight === "string") { const cached = blocksByHash.get(hashOrHeight); if (cached) return cached.height; const block = await brk.getBlockV1(hashOrHeight, { signal: controller.signal, }); blocksByHash.set(hashOrHeight, block); return block.height; } return null; } /** @param {string | number | null | undefined} [hashOrHeight] */ async function goToCube(hashOrHeight) { if (!active) return; if (hashOrHeight === "tip") hashOrHeight = null; if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) { hashOrHeight = Number(hashOrHeight); } const existing = findCube(hashOrHeight); if (existing) { selectCube(existing, { scroll: "smooth" }); return; } for (const cube of blocksElement.children) { if (!cube.hasAttribute("data-projected")) { cube.setAttribute("data-skeleton", ""); } } element.dataset.loading = ""; try { const height = await resolveHeight(hashOrHeight); const startHash = await loadInitial(height); const cube = findCube(startHash); if (cube) selectCube(cube, { scroll: "instant" }); } catch (error) { if (!controller.signal.aborted) { console.error("explore chain load:", error); } } finally { delete element.dataset.loading; } } async function pollProjected() { try { renderProjected( await brk.getMempoolBlocks({ signal: controller.signal }), ); } catch (error) { if (!controller.signal.aborted) console.error("explore mempool:", error); } } async function poll() { if (!active || !reachedTip || polling) return; polling = true; await pollProjected(); try { appendNewerBlocks(await brk.getBlocksV1({ signal: controller.signal })); } catch (error) { if (!controller.signal.aborted) console.error("explore chain poll:", error); } finally { polling = false; } } async function hydrateOlderBatches() { if (hydratingOlder) return; const generation = olderGeneration; hydratingOlder = true; try { while ( active && generation === olderGeneration && olderBatches[0]?.generation === generation ) { await hydrateOlderBatch(olderBatches[0]); if (olderBatches[0]?.generation === generation) olderBatches.shift(); } } finally { if (generation === olderGeneration) hydratingOlder = false; } } /** @param {OlderBatch} batch */ async function hydrateOlderBatch(batch) { try { const blocks = await brk.getBlocksV1FromHeight(batch.startHeight, { signal: controller.signal, }); if (!batch.placeholders.some((placeholder) => placeholder.isConnected)) { return; } const cubes = [...blocks].reverse().map(createKnownEnteringConfirmedCube); for (let i = 0; i < batch.placeholders.length; i++) { const cube = cubes[i]; if (cube) batch.placeholders[i].replaceWith(cube); else batch.placeholders[i].remove(); } for (const cube of cubes) setConfirmedInterval(cube); const next = cubes.at(-1)?.nextElementSibling; if (next instanceof HTMLElement) setConfirmedInterval(next); if (blocks.length) { oldestHeight = blocks[blocks.length - 1].height; } else { oldestReservedHeight = oldestHeight; } reserveOlderRunway(); } catch (error) { if (!controller.signal.aborted) { for (const placeholder of batch.placeholders) placeholder.remove(); oldestReservedHeight = oldestHeight; console.error("explore older:", error); } } } async function loadNewer() { if (!active || loadingNewer || newestHeight === -1 || reachedTip) return; loadingNewer = true; try { const prevNewest = newestHeight; const blocks = await brk.getBlocksV1FromHeight( newestHeight + BLOCK_BATCH_SIZE, { signal: controller.signal }, ); if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) { reachedTip = true; await pollProjected(); } } catch (error) { if (!controller.signal.aborted) console.error("explore newer:", error); } finally { loadingNewer = false; } } /** @param {HTMLButtonElement} cube */ function prependConfirmed(cube) { const oldFirst = /** @type {HTMLElement | null} */ ( blocksElement.firstElementChild ); blocksElement.insertBefore(cube, oldFirst); if (oldFirst) setConfirmedInterval(oldFirst); } /** @param {HTMLButtonElement} cube */ function appendConfirmed(cube) { blocksElement.insertBefore(cube, firstProjectedCube()); setConfirmedInterval(cube); } /** @param {MempoolBlock[]} blocks */ function renderProjected(blocks) { const want = Math.min(blocks.length, PROJECTED_LIMIT); while (projectedCubes.length > want) { projectedCubes.pop()?.element.remove(); } while (projectedCubes.length < want) { const cube = createProjectedCube(); projectedCubes.push(cube); blocksElement.append(cube.element); } for (let i = 0; i < want; i++) { updateProjectedCube(projectedCubes[i], blocks[i]); } updateTipCube(); refreshProjected(); } function refreshProjected() { if (!projectedCubes.length || !newestTimestamp) return; const now = Math.floor(Date.now() / 1_000); const elapsed = Math.max(0, now - newestTimestamp); const updateLayout = !tipButton.hasAttribute("data-visible"); for (let i = 0; i < projectedCubes.length; i++) { const cube = projectedCubes[i]; const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS; const timestamp = now + i * TARGET_BLOCK_SECONDS; if (updateLayout) { cube.element.style.setProperty("--block-interval", String(interval)); } updateProjectedTime(cube, timestamp); } } function scheduleTipVisibilitySync() { if (tipSyncFrame) return; tipSyncFrame = window.requestAnimationFrame(() => { tipSyncFrame = 0; syncTipVisibility(); }); } /** @param {boolean} visible */ function setTipVisible(visible) { tipButton.toggleAttribute("data-visible", visible); tipButton.setAttribute("aria-hidden", String(!visible)); tipButton.tabIndex = visible ? 0 : -1; } function syncTipVisibility() { if (!reachedTip || newestHeight < 0 || !tipCube) { setTipVisible(false); return; } const visibleHeight = findVisibleConfirmedHeight(scrollElement, blocksElement); if (projectedCubes.some(({ element }) => isElementVisible(element))) { setTipVisible(false); return; } setTipVisible( visibleHeight != null ? newestHeight - visibleHeight > TIP_BLOCK_THRESHOLD : !isElementVisible(tipCube), ); } /** @param {Element} element */ function cubeDistanceFromViewport(element) { return distanceFromViewport(scrollElement, element, isHorizontal()); } /** @param {Element} element */ function isElementVisible(element) { return cubeDistanceFromViewport(element) === 0; } function shouldLoadNewer() { const cube = newestConfirmedCube(); return cube != null && cubeDistanceFromViewport(cube) <= EDGE_LOAD_DISTANCE; } scrollElement.addEventListener( "wheel", (event) => { reserveOlderRunway(olderWheelDelta(event, isHorizontal())); }, { passive: true }, ); scrollElement.addEventListener( "scroll", () => { scheduleTipVisibilitySync(); reserveOlderRunway(); if (reachedTip || loadingNewer) return; if (shouldLoadNewer()) void loadNewer(); }, { passive: true }, ); /** @param {boolean} nextActive */ function setActive(nextActive) { if (active === nextActive) return; active = nextActive; if (active) { controller = new AbortController(); if (newestHeight === -1) void goToCube(null); else void poll(); pollId = window.setInterval(() => void poll(), POLL_INTERVAL); return; } if (pollId !== undefined) { window.clearInterval(pollId); pollId = undefined; } if (tipSyncFrame) { window.cancelAnimationFrame(tipSyncFrame); tipSyncFrame = 0; } cancelJump(); controller.abort(); } return /** @type {const} */ ({ element, setActive, }); }