From 8fc2e71492dd81afcf53d9ca17ecfad143d25bb7 Mon Sep 17 00:00:00 2001 From: nym21 Date: Tue, 12 May 2026 22:32:53 +0200 Subject: [PATCH] website: snap --- docs/README.md | 4 +- website/index.html | 27 +- website/scripts/_types.js | 3 + website/scripts/explorer/chain.js | 8 +- website/scripts/explorer/index.js | 2 +- website/scripts/explorer/mempool.js | 89 ++++ website/src/explorer/chain/cube/index.js | 60 +++ website/src/explorer/chain/cube/style.css | 191 +++++++++ website/src/explorer/chain/index.js | 481 ++++++++++++++++++++++ website/src/explorer/chain/style.css | 151 +++++++ website/styles/panes/explorer.css | 162 +++----- 11 files changed, 1050 insertions(+), 128 deletions(-) create mode 100644 website/scripts/explorer/mempool.js create mode 100644 website/src/explorer/chain/cube/index.js create mode 100644 website/src/explorer/chain/cube/style.css create mode 100644 website/src/explorer/chain/index.js create mode 100644 website/src/explorer/chain/style.css diff --git a/docs/README.md b/docs/README.md index 715539e8d..10a9af379 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ Open-source Bitcoin data toolkit that can parse blocks, index the chain, compute **Blockchain:** Blocks, transactions, addresses, UTXOs. -**Metrics:** Supply distributions, holder cohorts, network activity, fee markets, mining, and market indicators (realized cap, MVRV, SOPR, NVT). +**Metrics:** Supply distributions, holder cohorts, network activity, fee markets, mining, and market indicators. **Indexes:** Date, height, halving epoch, address type, UTXO age. @@ -67,7 +67,7 @@ Build custom applications in Rust. Use the full stack or individual components ( ## Supporters -- [OpenSats](https://opensats.org/) (December 2024 - June 2026) +- [OpenSats](https://opensats.org/) (December 2024 - May 2027) [Become a supporter](mailto:support@bitcoinresearchkit.org) diff --git a/website/index.html b/website/index.html index b54a86bb3..9d1677065 100644 --- a/website/index.html +++ b/website/index.html @@ -12,17 +12,19 @@ - - - - + + + + + - - + + + @@ -97,9 +99,18 @@ - + - + diff --git a/website/scripts/_types.js b/website/scripts/_types.js index 40eb864c1..9a988f925 100644 --- a/website/scripts/_types.js +++ b/website/scripts/_types.js @@ -63,6 +63,9 @@ * @typedef {Brk.AddrStats} AddrStats * @typedef {Brk.TxIn} TxIn * @typedef {Brk.TxOut} TxOut + * @typedef {Brk.BlockTemplate} BlockTemplate + * @typedef {Brk.MempoolBlock} MempoolBlock + * @typedef {Brk.NextBlockHash} NextBlockHash * ActivePriceRatioPattern: ratio pattern with price (extended) * @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern * PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev) diff --git a/website/scripts/explorer/chain.js b/website/scripts/explorer/chain.js index 0bf76d033..b683cb74a 100644 --- a/website/scripts/explorer/chain.js +++ b/website/scripts/explorer/chain.js @@ -1,6 +1,7 @@ import { brk } from "../utils/client.js"; import { onPlainClick } from "../utils/dom.js"; import { createCube } from "./cube.js"; +import { initMempool, renderMempool } from "./mempool.js"; import { createHeightElement, formatFeeRate } from "./render.js"; const LOOKAHEAD = 15; @@ -59,6 +60,8 @@ export function initChain(parent, callbacks) { blocksEl.classList.add("blocks"); scrollEl.append(blocksEl); + initMempool(scrollEl); + olderObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) loadOlder(); @@ -208,7 +211,10 @@ export async function goToCube(hashOrHeight, { silent } = {}) { } export async function poll() { - if (newestHeight === -1 || !reachedTip) return; + if (!reachedTip) return; + brk.getMempoolBlocks() + .then(renderMempool) + .catch((e) => console.error("mempool poll:", e)); try { const blocks = await brk.getBlocksV1(); appendNewerBlocks(blocks); diff --git a/website/scripts/explorer/index.js b/website/scripts/explorer/index.js index 122463b9e..ecc231704 100644 --- a/website/scripts/explorer/index.js +++ b/website/scripts/explorer/index.js @@ -6,7 +6,7 @@ import { poll, selectCube, deselectCube, -} from "./chain.js"; +} from "../../src/explorer/chain/index.js"; import { initBlockDetails, update as updateBlock, diff --git a/website/scripts/explorer/mempool.js b/website/scripts/explorer/mempool.js new file mode 100644 index 000000000..c4df67444 --- /dev/null +++ b/website/scripts/explorer/mempool.js @@ -0,0 +1,89 @@ +import { createCube } from "./cube.js"; +import { formatFeeRate } from "./render.js"; + +const NUM_BLOCKS = 8; + +/** + * @typedef {{ + * el: HTMLElement, + * topFace: HTMLDivElement, + * rightFace: HTMLDivElement, + * leftFace: HTMLDivElement, + * }} Cube + */ + +/** @type {HTMLDivElement | null} */ let mempoolBlocksEl = null; +/** @type {Cube[]} */ const cubes = []; + +/** @param {HTMLElement} parent the `.chain-scroll` element */ +export function initMempool(parent) { + mempoolBlocksEl = document.createElement("div"); + mempoolBlocksEl.classList.add("mempool-blocks"); + mempoolBlocksEl.hidden = true; + parent.prepend(mempoolBlocksEl); +} + +/** @param {MempoolBlock[]} blocks */ +export function renderMempool(blocks) { + if (!mempoolBlocksEl) return; + mempoolBlocksEl.hidden = blocks.length === 0; + const want = Math.min(blocks.length, NUM_BLOCKS); + while (cubes.length > want) { + const last = cubes.pop(); + if (last) last.el.remove(); + } + while (cubes.length < want) { + const cube = createMempoolCube(cubes.length); + cubes.push(cube); + mempoolBlocksEl.append(cube.el); + } + for (let i = 0; i < want; i++) updateMempoolCube(cubes[i], blocks[i], i); +} + +/** @param {number} position @returns {Cube} */ +function createMempoolCube(position) { + const el = document.createElement("div"); + el.classList.add("cube", "projected"); + if (position === 0) el.classList.add("next"); + const { topFace, rightFace, leftFace } = createCube(el, 0); + return { el, topFace, rightFace, leftFace }; +} + +/** + * @param {Cube} cube + * @param {MempoolBlock} block + * @param {number} position + */ +function updateMempoolCube(cube, block, position) { + const fill = Math.min(1, block.blockVSize / 1_000_000); + cube.el.style.setProperty("--fill", String(fill)); + + cube.topFace.textContent = ""; + const label = document.createElement("p"); + label.textContent = position === 0 ? "next" : `+${position}`; + cube.topFace.append(label); + + cube.rightFace.textContent = ""; + const txs = document.createElement("p"); + txs.textContent = block.nTx.toLocaleString(); + const txsUnit = document.createElement("p"); + txsUnit.classList.add("dim"); + txsUnit.textContent = block.nTx === 1 ? "tx" : "txs"; + cube.rightFace.append(txs, txsUnit); + + cube.leftFace.textContent = ""; + const median = document.createElement("p"); + const tilde = document.createElement("span"); + tilde.classList.add("dim"); + tilde.textContent = "~"; + median.append(tilde, formatFeeRate(block.medianFee)); + const range = document.createElement("p"); + const dash = document.createElement("span"); + dash.classList.add("dim"); + dash.textContent = "-"; + range.append(formatFeeRate(block.feeRange[0]), dash, formatFeeRate(block.feeRange[6])); + const unit = document.createElement("p"); + unit.classList.add("dim"); + unit.textContent = "sat/vB"; + cube.leftFace.append(median, range, unit); +} diff --git a/website/src/explorer/chain/cube/index.js b/website/src/explorer/chain/cube/index.js new file mode 100644 index 000000000..03e84254a --- /dev/null +++ b/website/src/explorer/chain/cube/index.js @@ -0,0 +1,60 @@ +/** + * @param {number} [fill] + * @returns {{ el: HTMLAnchorElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }} + */ +export function createCubeAnchor(fill = 1) { + const el = document.createElement("a"); + return { el, ...populateCube(el, fill) }; +} + +/** + * @param {number} [fill] + * @returns {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }} + */ +export function createCubeDiv(fill = 1) { + const el = document.createElement("div"); + return { el, ...populateCube(el, fill) }; +} + +/** + * @param {HTMLElement} el + * @param {number} fill + */ +function populateCube(el, fill) { + el.classList.add("cube"); + el.style.setProperty("--fill", String(fill)); + + const topFace = createFace("face-text", "top"); + const rightFace = createFace("face-text", "right"); + const leftFace = createFace("face-text", "left"); + + el.append( + createFace("glass", "bottom"), + createFace("glass", "rear-right"), + createFace("glass", "rear-left"), + createFace("liquid", "bottom"), + createFace("liquid", "rear-right"), + createFace("liquid", "rear-left"), + createFace("liquid", "right"), + createFace("liquid", "left"), + createFace("liquid", "top"), + createFace("glass", "right"), + createFace("glass", "left"), + createFace("glass", "top"), + rightFace, + leftFace, + topFace, + ); + + return { topFace, rightFace, leftFace }; +} + +/** + * @param {string} role + * @param {string} position + * */ +function createFace(role, position) { + const div = document.createElement("div"); + div.className = `face ${role} ${position}`; + return div; +} diff --git a/website/src/explorer/chain/cube/style.css b/website/src/explorer/chain/cube/style.css new file mode 100644 index 000000000..cd99cc7c7 --- /dev/null +++ b/website/src/explorer/chain/cube/style.css @@ -0,0 +1,191 @@ +:root { + --cube-size: 4.5rem; + --iso-scale: calc(sqrt(3) / 2); + --cube-empty-alpha: 0.4; + --cube-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1); + --face-step: 0.033; +} + +@keyframes cube-pulse { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.1); } +} + +.cube { + --cube-width: calc(var(--cube-size) * 2 * var(--iso-scale)); + --cube-height: calc(var(--cube-size) * 2); + + --face-base-light: var(--light-gray); + --face-base-dark: var(--dark-gray); + --face-bottom-base: var(--border-color); + + /* Light mode darkens from base, dark mode lightens. The asymmetry + can't be encoded in a single base, so each face picks its branch + from the matching flat base. State classes swap the bases only, + the per-face pattern stays. */ + --face-top: light-dark( + var(--face-base-light), + oklch(from var(--face-base-dark) calc(l + var(--face-step) * 2) c h) + ); + --face-right: light-dark( + oklch(from var(--face-base-light) calc(l - var(--face-step) * 2) c h), + var(--face-base-dark) + ); + --face-left: light-dark( + oklch(from var(--face-base-light) calc(l - var(--face-step)) c h), + oklch(from var(--face-base-dark) calc(l + var(--face-step)) c h) + ); + --face-bottom: oklch(from var(--face-bottom-base) calc(l - var(--face-step) * 3) c h); + + --is-full: round(down, var(--fill), 1); + --is-empty: round(down, calc(1 - var(--fill)), 1); + + flex-shrink: 0; + position: relative; + width: var(--cube-width); + height: var(--cube-height); + /* .cube can be an ; reset anchor styles that would clip the iso + silhouette or underline the empty link. */ + overflow: visible; + text-decoration: none; + color: var(--color); + transition: color var(--cube-ease); + user-select: none; + pointer-events: none; + + &:hover { + color: var(--background-color); + --face-base-light: var(--dark-gray); + --face-base-dark: var(--light-gray); + --face-bottom-base: var(--inv-border-color); + } + + /* Color states: override --face-* directly with a fixed + darken-from-base derivation so the cube renders identically in + light and dark mode (no theme-flip on the colored faces). */ + &:active, + &.selected { color: var(--black); --face-color-base: var(--orange); } + &.next { --face-color-base: var(--cyan); } + &.projected{ --face-color-base: var(--off-color); } + + &:active, + &.selected, + &.next, + &.projected { + --face-top: var(--face-color-base); + --face-right: oklch(from var(--face-color-base) calc(l - var(--face-step) * 2) c h); + --face-left: oklch(from var(--face-color-base) calc(l - var(--face-step)) c h); + --face-bottom: oklch(from var(--face-color-base) calc(l - var(--face-step) * 3) c h); + } + + &.next, + &.projected { + animation: cube-pulse 2.5s ease-in-out infinite; + } + + /* visibility (not color:transparent) so child hides too */ + &.skeleton .face-text { + visibility: hidden; + } + + .face { + position: absolute; + transform-origin: 0 0; + box-sizing: border-box; + width: var(--cube-size); + height: var(--cube-size); + transform: + translateY(50%) + var(--face-orient) + translate(calc(var(--cube-size) * var(--face-x)), calc(var(--cube-size) * var(--face-y))) + scale(var(--face-scale-x, 1), var(--face-scale-y)); + pointer-events: auto; + } + + /* will-change on painted roles only so each gets its own compositor + layer for snappy hover/select repaints. */ + .liquid, + .glass { + will-change: background-color; + transition: background-color var(--cube-ease); + } + .liquid { + background: var(--face-color); + opacity: calc(1 - var(--is-empty)); + --face-scale-y: calc(var(--iso-scale) * var(--fill)); + --face-stack-shift: calc(var(--iso-scale) * (1 - var(--fill))); + } + .glass { + background: oklch(from var(--face-color) l c h / var(--cube-empty-alpha)); + --face-scale-y: calc(var(--iso-scale) * (1 - var(--fill))); + --face-stack-shift: 0; + } + .glass.top { + opacity: calc(1 - var(--is-full)); + } + + .face-text { + --face-scale-y: var(--iso-scale); + --face-stack-shift: 0; + pointer-events: none; + padding: 0.1rem; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 450; + } + .face-text.top, + .face-text.right { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + .face-text.top { + justify-content: center; + text-transform: uppercase; + } + .face-text.right { + justify-content: space-between; + } + .face-text p { + margin: 0; + } + + .top, + .bottom { + --face-orient: rotate(30deg) skewX(-30deg); + --face-scale-y: var(--iso-scale); + } + .right, + .rear-left { + --face-orient: rotate(-30deg) skewX(-30deg); + } + .left, + .rear-right { + --face-orient: rotate(30deg) skewX(30deg); + } + + .top, + .rear-right { + --face-y: calc(var(--face-stack-shift) - var(--iso-scale)); + } + .left, + .rear-left { + --face-y: var(--face-stack-shift); + } + .right { + --face-y: calc(var(--face-stack-shift) + var(--iso-scale)); + } + .bottom { + --face-y: 0; + } + + .top { --face-color: var(--face-top); --face-x: 0; } + .bottom { --face-color: var(--face-bottom); --face-x: 1; } + .right { --face-color: var(--face-right); --face-x: 1; } + .left { --face-color: var(--face-left); --face-x: 0; } + .rear-right { --face-color: var(--face-left); --face-x: 1; } + .rear-left { --face-color: var(--face-top); --face-x: 1; --face-scale-x: -1; } + /* Top liquid face slides as fill drops, animating the surface level. */ + .liquid.top { --face-x: calc(1 - var(--fill)); } +} diff --git a/website/src/explorer/chain/index.js b/website/src/explorer/chain/index.js new file mode 100644 index 000000000..60c0fe626 --- /dev/null +++ b/website/src/explorer/chain/index.js @@ -0,0 +1,481 @@ +import { brk } from "../../../scripts/utils/client.js"; +import { onPlainClick } from "../../../scripts/utils/dom.js"; +import { + createHeightElement, + formatFeeRate, +} from "../../../scripts/explorer/render.js"; +import { createCubeAnchor, createCubeDiv } from "./cube/index.js"; + +const LOOKAHEAD = 15; +const PROJECTED_LIMIT = 8; +const TARGET_BLOCK_SECONDS = 600; +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** @type {HTMLDivElement} */ let chainEl; +/** @type {HTMLDivElement} */ let scrollEl; +/** @type {HTMLDivElement} */ let confirmedEl; +/** @type {HTMLDivElement} */ let projectedEl; +/** @type {HTMLAnchorElement | null} */ let selectedCube = null; +/** @type {IntersectionObserver} */ let olderEdgeObserver; +/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {}; +/** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {}; +/** @type {() => void} */ let onTip = () => {}; +/** @type {() => void} */ let onGenesis = () => {}; + +/** @type {Map} */ +const blocksByHash = new Map(); +/** @type {Array<{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }>} */ +const projectedCubes = []; + +let newestHeight = -1; +let oldestHeight = Infinity; +let newestTimestamp = 0; +let loadingOlder = false; +let loadingNewer = false; +let reachedTip = false; + +/** + * @param {HTMLElement} parent + * @param {{ + * onSelect: (block: BlockInfoV1) => void, + * onCubeClick: (cube: HTMLAnchorElement) => void, + * onTip: () => void, + * onGenesis: () => void, + * }} callbacks + */ +export function initChain(parent, callbacks) { + onSelect = callbacks.onSelect; + onCubeClick = callbacks.onCubeClick; + onTip = callbacks.onTip; + onGenesis = callbacks.onGenesis; + + chainEl = document.createElement("div"); + chainEl.id = "chain"; + parent.append(chainEl); + + chainEl.append( + createEdgeLink("tip", "/block/tip", "Jump to chain tip", onTip), + createEdgeLink("gen", "/block/0", "Jump to genesis block", onGenesis), + ); + + scrollEl = document.createElement("div"); + scrollEl.classList.add("chain-scroll"); + chainEl.append(scrollEl); + + projectedEl = document.createElement("div"); + projectedEl.classList.add("projected"); + projectedEl.hidden = true; + scrollEl.append(projectedEl); + + confirmedEl = document.createElement("div"); + confirmedEl.classList.add("confirmed"); + scrollEl.append(confirmedEl); + + olderEdgeObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) loadOlder(); + }, + { root: scrollEl }, + ); + + scrollEl.addEventListener( + "scroll", + () => { + if (reachedTip || loadingNewer) return; + if (scrollEl.scrollTop <= 50 && scrollEl.scrollLeft <= 50) loadNewer(); + }, + { passive: true }, + ); +} + +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 } = {}) { + if (cube !== selectedCube) { + 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); + } + } +} + +/** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */ +export async function goToCube(hashOrHeight, { silent } = {}) { + if (hashOrHeight === "tip") hashOrHeight = null; + 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 confirmedEl.children) cube.classList.add("skeleton"); + let startHash; + try { + const height = await resolveHeight(hashOrHeight); + startHash = await loadInitial(height); + } catch (_) { + try { + startHash = await loadInitial(null); + } catch (_) { + return; + } + } + selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), { + scroll: "instant", + silent, + }); +} + +export async function poll() { + if (!reachedTip) return; + brk + .getMempoolBlocks() + .then(renderProjected) + .catch((e) => console.error("mempool poll:", e)); + try { + const blocks = await brk.getBlocksV1(); + appendNewerBlocks(blocks); + } catch (e) { + console.error("explorer poll:", e); + } +} + +/** @param {BlockHash | Height | null} [hashOrHeight] */ +function findCube(hashOrHeight) { + if (hashOrHeight == null) { + return reachedTip && newestHeight >= 0 + ? /** @type {HTMLAnchorElement | null} */ (confirmedEl.lastElementChild) + : null; + } + const attr = typeof hashOrHeight === "number" ? "height" : "hash"; + return /** @type {HTMLAnchorElement | null} */ ( + confirmedEl.querySelector(`[data-${attr}="${hashOrHeight}"]`) + ); +} + +function clear() { + newestHeight = -1; + oldestHeight = Infinity; + newestTimestamp = 0; + loadingOlder = false; + loadingNewer = false; + reachedTip = false; + selectedCube = null; + confirmedEl.innerHTML = ""; + olderEdgeObserver.disconnect(); +} + +function observeOldestEdge() { + olderEdgeObserver.disconnect(); + const oldest = confirmedEl.firstElementChild; + if (oldest) olderEdgeObserver.observe(oldest); +} + +/** @param {BlockInfoV1[]} blocks */ +function appendNewerBlocks(blocks) { + if (!blocks.length) return false; + const anchor = confirmedEl.lastElementChild; + const anchorRect = anchor?.getBoundingClientRect(); + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.height > newestHeight) appendConfirmed(createConfirmedCube(b)); + else blocksByHash.set(b.id, b); + } + newestHeight = Math.max(newestHeight, blocks[0].height); + newestTimestamp = blocks[0].timestamp; + refreshProjectedIntervals(); + if (anchor && anchorRect) { + const r = anchor.getBoundingClientRect(); + scrollEl.scrollTop += r.top - anchorRect.top; + scrollEl.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) prependConfirmed(createConfirmedCube(b)); + newestHeight = blocks[0].height; + oldestHeight = blocks[blocks.length - 1].height; + newestTimestamp = blocks[0].timestamp; + 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; +} + +async function loadOlder() { + if (loadingOlder || oldestHeight <= 0) return; + loadingOlder = true; + try { + const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1); + for (const block of blocks) prependConfirmed(createConfirmedCube(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 {BlockInfoV1} block */ +function createConfirmedCube(block) { + const { pool, medianFee, feeRange, virtualSize } = block.extras; + const fill = Math.min(1, virtualSize / 1_000_000); + const { el, topFace, rightFace, leftFace } = createCubeAnchor(fill); + el.href = `/block/${block.id}`; + el.dataset.hash = block.id; + el.dataset.height = String(block.height); + el.dataset.timestamp = String(block.timestamp); + blocksByHash.set(block.id, block); + onPlainClick(el, () => onCubeClick(el)); + + 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); + + 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(pool.name)}.svg`; + logo.alt = ""; + logo.onerror = () => { + logo.onerror = null; + logo.src = "/assets/pools/default.svg"; + }; + const nameSpan = document.createElement("span"); + nameSpan.textContent = pool.name.replace(/\s+(Pool|USA)$/i, "").trim(); + poolDiv.append(logo, nameSpan); + rightFace.append(heightP, poolDiv); + + 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 el; +} + +/** @param {HTMLElement} cube */ +function setConfirmedInterval(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("--block-interval", String(dt)); +} + +/** @param {HTMLAnchorElement} cube */ +function prependConfirmed(cube) { + const next = /** @type {HTMLElement | null} */ ( + confirmedEl.firstElementChild + ); + confirmedEl.prepend(cube); + if (next) setConfirmedInterval(next); +} + +/** @param {HTMLAnchorElement} cube */ +function appendConfirmed(cube) { + confirmedEl.append(cube); + setConfirmedInterval(cube); +} + +/** @param {MempoolBlock[]} blocks */ +function renderProjected(blocks) { + const want = Math.min(blocks.length, PROJECTED_LIMIT); + projectedEl.hidden = want === 0; + + while (projectedCubes.length > want) { + const last = projectedCubes.pop(); + if (last) last.el.remove(); + } + while (projectedCubes.length < want) { + const cube = createProjectedCube(projectedCubes.length); + projectedCubes.push(cube); + projectedEl.append(cube.el); + } + for (let i = 0; i < want; i++) + updateProjectedCube(projectedCubes[i], blocks[i], i); + refreshProjectedIntervals(); +} + +/** @param {number} index */ +function createProjectedCube(index) { + const { el, topFace, rightFace, leftFace } = createCubeDiv(0); + el.classList.add("projected"); + if (index === 0) el.classList.add("next"); + return { el, topFace, rightFace, leftFace }; +} + +/** + * @param {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }} cube + * @param {MempoolBlock} block + * @param {number} index + */ +function updateProjectedCube(cube, block, index) { + const fill = Math.min(1, block.blockVSize / 1_000_000); + cube.el.style.setProperty("--fill", String(fill)); + + cube.topFace.textContent = ""; + const label = document.createElement("p"); + label.textContent = index === 0 ? "next" : `+${index}`; + cube.topFace.append(label); + + cube.rightFace.textContent = ""; + const txs = document.createElement("p"); + txs.textContent = block.nTx.toLocaleString(); + const txsUnit = document.createElement("p"); + txsUnit.classList.add("dim"); + txsUnit.textContent = block.nTx === 1 ? "tx" : "txs"; + cube.rightFace.append(txs, txsUnit); + + cube.leftFace.textContent = ""; + const median = document.createElement("p"); + median.append(span("~", "dim"), formatFeeRate(block.medianFee)); + const range = document.createElement("p"); + range.append( + formatFeeRate(block.feeRange[0]), + span("-", "dim"), + formatFeeRate(block.feeRange[6]), + ); + const unit = document.createElement("p"); + unit.classList.add("dim"); + unit.textContent = "sat/vB"; + cube.leftFace.append(median, range, unit); +} + +function refreshProjectedIntervals() { + if (!projectedCubes.length || !newestTimestamp) return; + const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - newestTimestamp); + for (let i = 0; i < projectedCubes.length; i++) { + const interval = TARGET_BLOCK_SECONDS * i + elapsed; + projectedCubes[i].el.style.setProperty( + "--block-interval", + String(interval), + ); + } +} + +/** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */ +function createEdgeLink(label, href, title, handler) { + const a = document.createElement("a"); + a.classList.add("chain-edge", label); + a.href = href; + a.title = title; + a.textContent = label; + onPlainClick(a, handler); + return a; +} + +/** @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 {string} name */ +const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** @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"), + ]; +} diff --git a/website/src/explorer/chain/style.css b/website/src/explorer/chain/style.css new file mode 100644 index 000000000..e0cfce792 --- /dev/null +++ b/website/src/explorer/chain/style.css @@ -0,0 +1,151 @@ +#chain { + flex-shrink: 0; + position: relative; + + --min-gap: calc(var(--cube-size) * -1); + --max-gap: calc(var(--cube-size) * 6); + --min-block-interval: 0; + --max-block-interval: 10800; + + @container aside (max-width: 767px) { + --min-gap: 0rem; + } + @container aside (min-width: 768px) { + height: 100%; + } + + .chain-scroll { + padding: 0 var(--main-padding); + + @container aside (max-width: 767px) { + padding-bottom: 1rem; + overflow-x: auto; + width: max-content; + } + + @container aside (min-width: 768px) { + padding: var(--main-padding) calc(var(--main-padding) / 2) 6rem + var(--main-padding); + height: 100%; + overflow-y: auto; + } + } + + .chain-edge { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + font-size: var(--font-size-xs); + text-transform: uppercase; + text-decoration: none; + letter-spacing: 0.1em; + font-weight: 500; + + @container aside (max-width: 767px) { + height: 100%; + width: var(--main-padding); + writing-mode: vertical-lr; + text-orientation: upright; + } + + @container aside (min-width: 768px) { + width: 100%; + height: var(--main-padding); + padding-left: calc(var(--main-padding) / 2); + } + } + + .tip { + top: 0; + left: 0; + } + .gen { + bottom: 0; + right: 0; + } + + .projected, + .confirmed { + display: flex; + flex-direction: column-reverse; + @container aside (max-width: 767px) { + flex-direction: row-reverse; + } + } + + .cube { + --cube-fall-off: pow( + clamp( + 0, + (var(--block-interval, 600) - var(--min-block-interval)) / + (var(--max-block-interval) - var(--min-block-interval)), + 1 + ), + 0.7 + ); + --block-gap: calc( + var(--min-gap) + var(--cube-fall-off) * (var(--max-gap) - var(--min-gap)) + ); + + & + & { + margin-bottom: var(--block-gap); + + &::before { + content: ""; + position: absolute; + top: 100%; + left: 50%; + width: 1px; + height: var(--block-gap); + background: var(--border-color); + z-index: -1; + } + + @container aside (max-width: 767px) { + margin-bottom: 0; + margin-right: var(--block-gap); + + &::before { + top: 50%; + left: auto; + right: calc(-1 * var(--block-gap)); + width: var(--block-gap); + height: 1px; + } + } + } + + .face-text .height { + font-size: var(--font-size-sm); + font-weight: normal; + } + .face-text .fees { + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + align-items: center; + } + .face-text .pool { + display: flex; + align-items: center; + justify-content: center; + gap: 0.1em; + width: 100%; + + img { + width: 1.25em; + height: 1.25em; + flex-shrink: 0; + } + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1; + } + } + } +} diff --git a/website/styles/panes/explorer.css b/website/styles/panes/explorer.css index 020563ee7..15cee8f76 100644 --- a/website/styles/panes/explorer.css +++ b/website/styles/panes/explorer.css @@ -14,80 +14,15 @@ flex-direction: column; } - --cube: 4.5rem; - /* Iso geometry. --iso-scale is the half-width ratio of the hex - silhouette (= cos(30deg) = sqrt(3)/2); the silhouette spans - 2·iso-scale wide and 2 tall in cube-face units. The per-face - transforms use the HTML-demo math with ox=oy=0, so the outer - compensation on `.face` is just translateY(50%). */ - --iso-scale: calc(sqrt(3) / 2); - --cube-w: calc(var(--cube) * 2 * var(--iso-scale)); - --cube-h: calc(var(--cube) * 2); - --face-step: 0.033; - - /* Cube face-color derivations, resolved once at the #explorer - level so .cube state changes (hover / selected) don't force - Safari to re-evaluate `oklch(from var …)` on every paint — a - known source of jank there. Each interaction state gets its own - set; the .cube rule below just swaps which set --face-right / - --face-left / --face-top / --face-bottom reference. */ - --cube-neutral-right: light-dark( - oklch(from var(--light-gray) calc(l - var(--face-step) * 2) c h), - var(--dark-gray) - ); - --cube-neutral-left: light-dark( - oklch(from var(--light-gray) calc(l - var(--face-step)) c h), - oklch(from var(--dark-gray) calc(l + var(--face-step)) c h) - ); - --cube-neutral-top: light-dark( - var(--light-gray), - oklch(from var(--dark-gray) calc(l + var(--face-step) * 2) c h) - ); - --cube-neutral-bottom: oklch( - from var(--border-color) calc(l - var(--face-step) * 3) c h - ); - - --cube-hover-right: light-dark( - oklch(from var(--dark-gray) calc(l - var(--face-step) * 2) c h), - var(--light-gray) - ); - --cube-hover-left: light-dark( - oklch(from var(--dark-gray) calc(l - var(--face-step)) c h), - oklch(from var(--light-gray) calc(l + var(--face-step)) c h) - ); - --cube-hover-top: light-dark( - var(--dark-gray), - oklch(from var(--light-gray) calc(l + var(--face-step) * 2) c h) - ); - --cube-hover-bottom: oklch( - from var(--inv-border-color) calc(l - var(--face-step) * 3) c h - ); - - --cube-selected-right: light-dark( - oklch(from var(--orange) calc(l - var(--face-step) * 2) c h), - var(--orange) - ); - --cube-selected-left: light-dark( - oklch(from var(--orange) calc(l - var(--face-step)) c h), - oklch(from var(--orange) calc(l + var(--face-step)) c h) - ); - --cube-selected-top: light-dark( - var(--orange), - oklch(from var(--orange) calc(l + var(--face-step) * 2) c h) - ); - --cube-selected-bottom: oklch( - from var(--orange) calc(l - var(--face-step) * 3) c h - ); - - > * { + /*> * { padding: 0 var(--main-padding); @container aside (min-width: 768px) { padding: var(--main-padding); } - } + }*/ - #chain { + /*#chain { flex-shrink: 0; position: relative; padding: 0; @@ -141,13 +76,22 @@ } .tip { - @container aside (min-width: 768px) { top: 0; } - @container aside (max-width: 767px) { left: 0; } + @container aside (min-width: 768px) { + top: 0; + } + @container aside (max-width: 767px) { + left: 0; + } } .gen { - @container aside (min-width: 768px) { bottom: 0; } - @container aside (max-width: 767px) { top: 0; right: 0; } + @container aside (min-width: 768px) { + bottom: 0; + } + @container aside (max-width: 767px) { + top: 0; + right: 0; + } } .blocks { @@ -183,18 +127,11 @@ ); --empty-alpha: 0.4; - /* Face colors reference the precomputed sets on #explorer - (see top of file). Hover / selected rules just switch which - set each --face-* points at. */ --face-right: var(--cube-neutral-right); --face-left: var(--cube-neutral-left); --face-top: var(--cube-neutral-top); --face-bottom: var(--cube-neutral-bottom); - /* Fill-driven state. --liquid-y is the liquid's vertical scale; - --glass-y the glass-above-liquid's. --is-full / --is-empty - are 1 only at the exact endpoints (fill == 1 / fill == 0) - and drive opacity so those roles hide cleanly there. */ --liquid-y: calc(var(--iso-scale) * var(--fill)); --glass-y: calc(var(--iso-scale) * (1 - var(--fill))); --is-full: round(down, var(--fill), 1); @@ -205,9 +142,6 @@ cursor: pointer; width: var(--cube-w); height: var(--cube-h); - /* .cube is an ; reset the global anchor styles in - elements.css that would clip the iso silhouette - (overflow:hidden) and underline the empty link. */ overflow: visible; text-decoration: none; --state-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1); @@ -233,40 +167,22 @@ --face-bottom: var(--cube-selected-bottom); } - /* Skeleton state (cube painted but data is stale while a new - chunk loads): hide text AND the pool logo. Using visibility - rather than color:transparent so the raw logo hides too. */ &.skeleton .face-text { visibility: hidden; } - /* Shared face-transform template. Each face div sets --orient, - --x, --y, --sx, --sy and its role (liquid/glass/face-text) - supplies --y-offset. Faces extend outside .cube's layout box - — the iso silhouette spans ~2·iso·cube × 2·cube, offset into - what would be the next cube's space. Clicks land only on the - transformed face rectangles, not the .cube's empty corners. */ .face { position: absolute; transform-origin: 0 0; box-sizing: border-box; width: var(--cube); height: var(--cube); - transform: - translateY(50%) - var(--orient) + transform: translateY(50%) var(--orient) translate(calc(var(--cube) * var(--x)), calc(var(--cube) * var(--y))) scale(var(--sx, 1), var(--sy)); pointer-events: auto; } - /* Roles: - .liquid opaque liquid (scales with fill) - .glass translucent glass shell - .face-text text overlay spanning the full rhombus - will-change is on the painted roles only (not .face-text, - whose background never changes) so each liquid/glass gets its - own compositor layer for snappy hover/select repaints. */ .liquid, .glass { will-change: background-color; @@ -324,7 +240,6 @@ justify-content: center; align-items: center; } - /* Pool line: raw (un-tinted) logo + miner name, ellipsis-clipped. */ .face-text .pool { display: flex; align-items: center; @@ -345,8 +260,6 @@ line-height: 1; } - /* Per-face geometry. 3 orientations × 4 vertical anchors. Each - face picks one of each (plus its color/horizontal anchor). */ .top, .bottom { --orient: rotate(30deg) skewX(-30deg); @@ -361,9 +274,6 @@ --orient: rotate(30deg) skewX(30deg); } - /* Vertical anchors (cube-face units from the layout origin). - --y-offset is the role-specific fill shift (liquid sides - get glass-y, everything else 0). */ .top, .rear-right { --y: calc(var(--y-offset) - var(--iso-scale)); @@ -379,14 +289,34 @@ --y: 0; } - /* Per-face color + horizontal anchor. */ - .top { --fc: var(--face-top); --x: var(--top-x-shift, 0); } - .bottom { --fc: var(--face-bottom); --x: 1; } - .right { --fc: var(--face-right); --x: 1; } - .left { --fc: var(--face-left); --x: 0; } - .rear-right { --fc: var(--face-left); --x: 1; } - .rear-left { --fc: var(--face-top); --x: 1; --sx: -1; } - .liquid.top { --top-x-shift: calc(1 - var(--fill)); } + .top { + --fc: var(--face-top); + --x: var(--top-x-shift, 0); + } + .bottom { + --fc: var(--face-bottom); + --x: 1; + } + .right { + --fc: var(--face-right); + --x: 1; + } + .left { + --fc: var(--face-left); + --x: 0; + } + .rear-right { + --fc: var(--face-left); + --x: 1; + } + .rear-left { + --fc: var(--face-top); + --x: 1; + --sx: -1; + } + .liquid.top { + --top-x-shift: calc(1 - var(--fill)); + } & + & { margin-bottom: var(--block-gap); @@ -417,7 +347,7 @@ } } } - } + }*/ #block-details, #tx-details,