import { brk } from "../utils/client.js"; import { createCube } from "./cube.js"; import { createHeightElement, formatFeeRate } from "./render.js"; const LOOKAHEAD = 15; /** @type {HTMLDivElement} */ let chainEl; /** @type {HTMLDivElement} */ let blocksEl; /** @type {HTMLAnchorElement | null} */ let selectedCube = null; /** @type {IntersectionObserver} */ let olderObserver; /** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {}; /** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {}; /** @type {Map} */ const blocksByHash = new Map(); let newestHeight = -1; let oldestHeight = Infinity; let loadingOlder = false; let loadingNewer = false; let reachedTip = false; /** * @param {HTMLElement} parent * @param {{ onSelect: (block: BlockInfoV1) => void, onCubeClick: (cube: HTMLAnchorElement) => void }} callbacks */ export function initChain(parent, callbacks) { onSelect = callbacks.onSelect; onCubeClick = callbacks.onCubeClick; chainEl = document.createElement("div"); chainEl.id = "chain"; parent.append(chainEl); blocksEl = document.createElement("div"); blocksEl.classList.add("blocks"); chainEl.append(blocksEl); olderObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) loadOlder(); }, { root: chainEl }, ); chainEl.addEventListener( "scroll", () => { if (reachedTip || loadingNewer) return; if (chainEl.scrollTop <= 50 && chainEl.scrollLeft <= 50) loadNewer(); }, { passive: true }, ); } /** @param {BlockHash | Height | null} [hashOrHeight] */ function findCube(hashOrHeight) { if (hashOrHeight == null) { return reachedTip && newestHeight >= 0 ? /** @type {HTMLAnchorElement | null} */ (blocksEl.lastElementChild) : null; } const attr = typeof hashOrHeight === "number" ? "height" : "hash"; return /** @type {HTMLAnchorElement | null} */ ( blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`) ); } export function deselectCube() { if (selectedCube) selectedCube.classList.remove("selected"); selectedCube = null; } /** @param {HTMLAnchorElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */ export function selectCube(cube, { scroll, silent } = {}) { const changed = cube !== selectedCube; if (changed) { if (selectedCube) selectedCube.classList.remove("selected"); selectedCube = cube; cube.classList.add("selected"); } if (scroll) { cube.scrollIntoView({ behavior: scroll, block: "center", inline: "center" }); } if (!silent) { const hash = cube.dataset.hash; if (hash) { const block = blocksByHash.get(hash); if (block) onSelect(block); } } } function clear() { newestHeight = -1; oldestHeight = Infinity; loadingOlder = false; loadingNewer = false; reachedTip = false; selectedCube = null; blocksEl.innerHTML = ""; olderObserver.disconnect(); } function observeOldestEdge() { olderObserver.disconnect(); const oldest = blocksEl.firstElementChild; if (oldest) olderObserver.observe(oldest); } /** @param {BlockInfoV1[]} blocks */ function appendNewerBlocks(blocks) { if (!blocks.length) return false; const anchor = blocksEl.lastElementChild; const anchorRect = anchor?.getBoundingClientRect(); for (let i = blocks.length - 1; i >= 0; i--) { const b = blocks[i]; if (b.height > newestHeight) { appendCube(createBlockCube(b)); } else { blocksByHash.set(b.id, b); } } newestHeight = Math.max(newestHeight, blocks[0].height); if (anchor && anchorRect) { const r = anchor.getBoundingClientRect(); chainEl.scrollTop += r.top - anchorRect.top; chainEl.scrollLeft += r.left - anchorRect.left; } return true; } /** @param {number | null} [height] @returns {Promise} */ async function loadInitial(height) { const blocks = height != null ? await brk.getBlocksV1FromHeight(height) : await brk.getBlocksV1(); clear(); for (const b of blocks) prependCube(createBlockCube(b)); newestHeight = blocks[0].height; oldestHeight = blocks[blocks.length - 1].height; reachedTip = height == null; observeOldestEdge(); if (!reachedTip) await loadNewer(); return blocks[0].id; } /** @param {BlockHash | Height | null} [hashOrHeight] @returns {Promise} */ 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); blocksByHash.set(hashOrHeight, block); return block.height; } return null; } /** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */ export async function goToCube(hashOrHeight, { silent } = {}) { if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) { hashOrHeight = Number(hashOrHeight); } let cube = findCube(hashOrHeight); if (cube) { selectCube(cube, { scroll: "smooth", silent }); return; } for (const cube of blocksEl.children) cube.classList.add("skeleton"); let startHash; try { const height = await resolveHeight(hashOrHeight); startHash = await loadInitial(height); } catch (e) { try { startHash = await loadInitial(null); } catch (_) { return; } } selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), { scroll: "instant", silent }); } export async function poll() { if (newestHeight === -1 || !reachedTip) return; try { const blocks = await brk.getBlocksV1(); appendNewerBlocks(blocks); } catch (e) { console.error("explorer poll:", e); } } async function loadOlder() { if (loadingOlder || oldestHeight <= 0) return; loadingOlder = true; try { const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1); for (const block of blocks) prependCube(createBlockCube(block)); if (blocks.length) { oldestHeight = blocks[blocks.length - 1].height; observeOldestEdge(); } } catch (e) { console.error("explorer loadOlder:", e); } loadingOlder = false; } async function loadNewer() { if (loadingNewer || newestHeight === -1 || reachedTip) return; loadingNewer = true; try { const prevNewest = newestHeight; const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD); if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) { reachedTip = true; } } catch (e) { console.error("explorer loadNewer:", e); } loadingNewer = false; } /** @param {string} name */ const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, ""); const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; /** @param {number} unixSec */ function formatShortDate(unixSec) { const d = new Date(unixSec * 1000); return `${MONTHS[d.getMonth()]} ${d.getDate()}`; } /** @param {number} unixSec */ function formatHHMM(unixSec) { const d = new Date(unixSec * 1000); return [String(d.getHours()).padStart(2, "0"), String(d.getMinutes()).padStart(2, "0")]; } /** @param {string} text @param {string} [cls] */ function span(text, cls) { const s = document.createElement("span"); if (cls) s.classList.add(cls); s.textContent = text; return s; } /** @param {BlockInfoV1} block */ function createBlockCube(block) { const cubeElement = document.createElement("a"); cubeElement.classList.add("cube"); cubeElement.href = `/block/${block.id}`; cubeElement.dataset.hash = block.id; cubeElement.dataset.height = String(block.height); cubeElement.dataset.timestamp = String(block.timestamp); const { pool, medianFee, feeRange, virtualSize } = block.extras; const fill = Math.min(1, virtualSize / 1_000_000); const { topFace, rightFace, leftFace } = createCube(cubeElement, fill); blocksByHash.set(block.id, block); // Intercept plain left-clicks for SPA nav; let modified clicks // (cmd/ctrl/shift/middle) and right-click fall through so the // anchor's native open-in-new-tab / context-menu behavior works. cubeElement.addEventListener("click", (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); onCubeClick(cubeElement); }); const minerName = pool.name; // Top: short date / HH:MM (colon dimmed). const dateP = document.createElement("p"); dateP.textContent = formatShortDate(block.timestamp); const [hh, mm] = formatHHMM(block.timestamp); const timeP = document.createElement("p"); timeP.append(hh, span(":", "dim"), mm); topFace.append(dateP, timeP); // Right: block height / raw pool-logo + miner name. const heightP = document.createElement("p"); heightP.classList.add("height"); heightP.append(createHeightElement(block.height)); const poolDiv = document.createElement("div"); poolDiv.classList.add("pool"); const logo = document.createElement("img"); logo.src = `/assets/pools/${poolSlug(minerName)}.svg`; logo.alt = ""; logo.onerror = () => { logo.onerror = null; logo.src = "/assets/pools/default.svg"; }; const nameSpan = document.createElement("span"); nameSpan.textContent = minerName.replace(/\s+(Pool|USA)$/i, "").trim(); poolDiv.append(logo, nameSpan); rightFace.append(heightP, poolDiv); // Left: ~median / min-max / sat/vB fees stack. const feesEl = document.createElement("div"); feesEl.classList.add("fees"); const avg = document.createElement("p"); avg.append(span("~", "dim"), formatFeeRate(medianFee)); const range = document.createElement("p"); range.append( formatFeeRate(feeRange[0]), span("-", "dim"), formatFeeRate(feeRange[6]), ); const unit = document.createElement("p"); unit.classList.add("dim"); unit.textContent = "sat/vB"; feesEl.append(avg, range, unit); leftFace.append(feesEl); return cubeElement; } /** @param {HTMLElement} cube */ function setGap(cube) { const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling); if (!prev) return; const dt = Math.max(0, Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp)); cube.style.setProperty("--dt", String(dt)); } /** @param {HTMLAnchorElement} cube */ function prependCube(cube) { const next = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild); blocksEl.prepend(cube); if (next) setGap(next); } /** @param {HTMLAnchorElement} cube */ function appendCube(cube) { blocksEl.append(cube); setGap(cube); }