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,