From d0d071659d4fa4e3490a25d15de466f5182f1406 Mon Sep 17 00:00:00 2001 From: Randall Naar Date: Wed, 29 Jul 2026 18:19:51 -0400 Subject: [PATCH 1/2] Added pending block component. --- client/src/app.js | 22 + client/src/components/block-grid.js | 36 +- .../src/components/transaction-block-grid.js | 1044 +++++++++++++++++ client/src/const.js | 1 + client/src/lib/block-template.js | 118 ++ client/src/lib/fees.js | 10 + client/src/lib/mempool.js | 21 + client/src/views/blocks.js | 24 + client/src/views/overview.js | 38 +- .../src/views/pending-block-details-card.js | 735 ++++++++++++ client/src/views/transactions.js | 11 +- www/style.css | 527 +++++++++ 12 files changed, 2542 insertions(+), 45 deletions(-) create mode 100644 client/src/components/transaction-block-grid.js create mode 100644 client/src/lib/block-template.js create mode 100644 client/src/lib/mempool.js create mode 100644 client/src/views/pending-block-details-card.js diff --git a/client/src/app.js b/client/src/app.js index 520f22f19..3276f48af 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -137,6 +137,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , togTx$ = click('[data-toggle-tx]').map(d => d.toggleTx).merge(page$.mapTo(null), expandTx$) , togBlock$ = click('[data-toggle-block]').map(d => d.toggleBlock).merge(page$.mapTo(null), expandBl$) + , togPendingBlockDetails$ = click('[data-toggle-pending-block-details]') , copy$ = click('[data-clipboard-copy]').map(d => d.clipboardCopy) , pushtx$ = (process.browser @@ -237,6 +238,11 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // Currently collapsed tx/block ("details") , openTx$ = togTx$.startWith(null).scan((prev, txid) => prev == txid ? null : txid) , openBlock$ = togBlock$.startWith(null).scan((prev, blockhash) => prev == blockhash ? null : blockhash) + , pendingBlockDetailsOpen$ = togPendingBlockDetails$ + .mapTo(open => !open) + .merge(page$.mapTo(_ => false)) + .startWith(false) + .scan((open, mod) => mod(open)) // Spending txs map (reset on every page nav) , spends$ = O.merge( @@ -251,6 +257,9 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , mempool$ = reply('mempool').startWith(null) , mempoolRecent$ = reply('recent') , newTxEntries$ = trackNewEntries(mempoolRecent$, tx => tx.txid) + , blockTemplate$ = isBitcoinNetwork + ? reply('block-template').startWith(null) + : O.of(null) // dashboard , dashboardPegAsset$ = !showPegData @@ -373,6 +382,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // App state , state$ = combine({ t$, error$, tipHeight$, spends$ , goBlocks$, blocks$, nextBlocks$, prevBlocks$, dashboardState$ + , pendingBlockDetailsOpen$, blockTemplate$ , dashboardEpochStartBlock$, dashboardPreviousDifficultyBlock$ , newBlockEntries$, newTxEntries$ , goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$ @@ -489,6 +499,18 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , { category: 'dashboard-peg-chain-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/chain`, bg: true } , { category: 'dashboard-peg-mempool-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/mempool`, bg: true }])) + // refresh the pending block template while the Bitcoin dashboard remains open + , isBitcoinNetwork + ? O.merge( + O.merge(goHome$, tickWhileViewing(30000, 'dashBoard', view$)) + .throttleTime(1000) + , latestBlock$.skip(1) + .withLatestFrom(view$) + .filter(([ _, view ]) => view == 'dashBoard') + ) + .mapTo({ category: 'block-template', method: 'GET', path: '/block-template', bg: true }) + : O.empty() + , goHome$.flatMap(_ => [{ category: 'blocks', method: 'GET', path: '/blocks' } , { category: 'recent', method: 'GET', path: '/mempool/recent' } , { category: 'fee-est', method: 'GET', path: '/fee-estimates' } diff --git a/client/src/components/block-grid.js b/client/src/components/block-grid.js index dc6ee8b90..1d4ccccbe 100644 --- a/client/src/components/block-grid.js +++ b/client/src/components/block-grid.js @@ -1,6 +1,7 @@ -import { maxBlockWeight } from "../const"; +import { blockGridLoadingDelayMs, maxBlockWeight } from "../const"; const GRID_LENGTH = 15; +const LOADING_GRID_LENGTH = 5; const drawBlockGrid = (canvas, blockWeight) => { if ( @@ -59,7 +60,35 @@ const drawGrid = (vnode, blockWeight) => { draw(); }; -export const BlockGrid = ({ blockWeight } = {}) => { +const BlockGridLoading = ({ loadingDelayMs }) => ( +
+ +
+); + +export const BlockGrid = ({ + blockWeight, + loadingDelayMs = blockGridLoadingDelayMs, +} = {}) => { const hasBlockWeight = Number.isFinite(blockWeight); const percentage = hasBlockWeight ? Math.min( @@ -83,6 +112,9 @@ export const BlockGrid = ({ blockWeight } = {}) => { hook-insert={(vnode) => drawGrid(vnode, blockWeight)} hook-postpatch={(_, vnode) => drawGrid(vnode, blockWeight)} > + {!hasBlockWeight ? ( + + ) : null} ); }; diff --git a/client/src/components/transaction-block-grid.js b/client/src/components/transaction-block-grid.js new file mode 100644 index 000000000..e8cc15019 --- /dev/null +++ b/client/src/components/transaction-block-grid.js @@ -0,0 +1,1044 @@ +const MAX_BLOCK_WEIGHT = 4_000_000; +const DEFAULT_CELLS_PER_SIDE = 75; +const DEFAULT_BACKGROUND_COLOR = "#1c1c1c"; +const DEFAULT_PLACEHOLDER_COLOR = "#262626"; +const TILE_GUTTER = 1; +const EXIT_DURATION = 320; +const REENTRY_GAP = 25; +const ENTER_DURATION = 420; +const ENTER_START = EXIT_DURATION + REENTRY_GAP; +const TRANSITION_DURATION = ENTER_START + ENTER_DURATION; +const ENTER_SCALE = 0.28; +const MOVING_OPACITY = 0.72; +const TOOLTIP_HIDE_DELAY = 120; +const mountedInstances = new WeakMap(); + +function assertOpaqueColor(color, colorName) { + if ( + typeof color !== "string" || + !color.trim() || + (window.CSS && !window.CSS.supports("color", color)) + ) { + throw new TypeError(`${colorName} must be a valid CSS color.`); + } + + const probe = document.createElement("canvas"); + probe.width = 1; + probe.height = 1; + const probeContext = probe.getContext("2d", { willReadFrequently: true }); + probeContext.clearRect(0, 0, 1, 1); + probeContext.fillStyle = color; + probeContext.fillRect(0, 0, 1, 1); + + if (probeContext.getImageData(0, 0, 1, 1).data[3] !== 255) { + throw new TypeError(`${colorName} must be opaque.`); + } +} + +function normalizeFeeTiers(feeTiers) { + if (!feeTiers || typeof feeTiers !== "object") { + throw new TypeError("Fee tiers must define low, medium, and high tiers."); + } + + const normalized = {}; + + ["low", "medium", "high"].forEach(tierName => { + const tier = feeTiers[tierName]; + if (!tier || typeof tier !== "object") { + throw new TypeError(`Fee tier "${tierName}" is required.`); + } + + const threshold = Number(tier.threshold); + if (!Number.isFinite(threshold)) { + throw new TypeError(`Fee tier "${tierName}" threshold must be finite.`); + } + + assertOpaqueColor(tier.color, `Fee tier "${tierName}" color`); + normalized[tierName] = { + threshold, + color: tier.color.trim() + }; + }); + + if ( + normalized.low.threshold >= normalized.medium.threshold || + normalized.medium.threshold >= normalized.high.threshold + ) { + throw new RangeError( + "Fee tier thresholds must be strictly ascending from low to medium to high." + ); + } + + return normalized; +} + +function normalizeOptions(options) { + if ( + options !== undefined && + (!options || typeof options !== "object" || Array.isArray(options)) + ) { + throw new TypeError("Block grid options must be an object."); + } + + const cellsPerSide = options?.cellsPerSide ?? DEFAULT_CELLS_PER_SIDE; + const backgroundColor = options?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR; + const placeholderColor = options?.placeholderColor ?? DEFAULT_PLACEHOLDER_COLOR; + + if (!Number.isInteger(cellsPerSide) || cellsPerSide <= 0) { + throw new RangeError("options.cellsPerSide must be a positive integer."); + } + + assertOpaqueColor(backgroundColor, "options.backgroundColor"); + assertOpaqueColor(placeholderColor, "options.placeholderColor"); + + return { + cellsPerSide, + backgroundColor: backgroundColor.trim(), + placeholderColor: placeholderColor.trim() + }; +} + +function normalizeTransactions(transactions) { + if (!Array.isArray(transactions)) { + throw new TypeError("Block grid transactions must be an array."); + } + + return transactions.map((transaction, index) => { + if (!transaction || typeof transaction !== "object") { + throw new TypeError(`Transaction at index ${index} must be an object.`); + } + + if (typeof transaction.txid !== "string" || !transaction.txid.trim()) { + throw new TypeError(`Transaction at index ${index} must have a non-empty txid.`); + } + + const fee = Number(transaction.fee); + const weight = Number(transaction.weight); + + if (!Number.isFinite(fee) || fee < 0) { + throw new RangeError( + `Transaction "${transaction.txid}" must have a non-negative finite fee.` + ); + } + + if (!Number.isFinite(weight) || weight <= 0) { + throw new RangeError( + `Transaction "${transaction.txid}" must have a positive finite weight.` + ); + } + + const virtualSize = Math.ceil(weight / 4); + return { + txid: transaction.txid, + fee, + weight, + virtualSize, + feeRate: fee / virtualSize, + inputIndex: index + }; + }); +} + +function intersects(first, second) { + return ( + first.x < second.x + second.width && + first.x + first.width > second.x && + first.y < second.y + second.height && + first.y + first.height > second.y + ); +} + +function contains(outer, inner) { + return ( + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + ); +} + +function splitFreeRect(freeRect, usedRect) { + if (!intersects(freeRect, usedRect)) return [freeRect]; + + const nextRects = []; + const freeRight = freeRect.x + freeRect.width; + const freeBottom = freeRect.y + freeRect.height; + const usedRight = usedRect.x + usedRect.width; + const usedBottom = usedRect.y + usedRect.height; + + if (usedRect.y > freeRect.y) { + nextRects.push({ + x: freeRect.x, + y: freeRect.y, + width: freeRect.width, + height: usedRect.y - freeRect.y + }); + } + + if (usedBottom < freeBottom) { + nextRects.push({ + x: freeRect.x, + y: usedBottom, + width: freeRect.width, + height: freeBottom - usedBottom + }); + } + + if (usedRect.x > freeRect.x) { + nextRects.push({ + x: freeRect.x, + y: freeRect.y, + width: usedRect.x - freeRect.x, + height: freeRect.height + }); + } + + if (usedRight < freeRight) { + nextRects.push({ + x: usedRight, + y: freeRect.y, + width: freeRight - usedRight, + height: freeRect.height + }); + } + + return nextRects.filter(rect => rect.width > 0 && rect.height > 0); +} + +function pruneFreeRects(freeRects) { + return freeRects.filter((rect, index) => { + return !freeRects.some((other, otherIndex) => { + return otherIndex !== index && contains(other, rect); + }); + }); +} + +function scorePlacement(freeRect, sideCells) { + return { + x: freeRect.x + freeRect.width - sideCells, + y: freeRect.y, + areaFit: freeRect.width * freeRect.height - sideCells * sideCells, + shortSideFit: Math.min( + freeRect.width - sideCells, + freeRect.height - sideCells + ), + longSideFit: Math.max( + freeRect.width - sideCells, + freeRect.height - sideCells + ) + }; +} + +function findPlacement(item, freeRects) { + let best = null; + + freeRects.forEach(freeRect => { + if (item.sideCells > freeRect.width || item.sideCells > freeRect.height) return; + + const placement = { + ...scorePlacement(freeRect, item.sideCells), + width: item.sideCells, + height: item.sideCells + }; + + if ( + !best || + placement.x > best.x || + (placement.x === best.x && placement.y < best.y) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit < best.shortSideFit + ) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit === best.shortSideFit && + placement.areaFit < best.areaFit + ) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit === best.shortSideFit && + placement.areaFit === best.areaFit && + placement.longSideFit < best.longSideFit + ) + ) { + best = placement; + } + }); + + return best; +} + +function packItems(items, gridDimensions) { + let freeRects = [{ + x: 0, + y: 0, + width: gridDimensions.columns, + height: gridDimensions.rows + }]; + const rects = []; + const sortedItems = [...items].sort((first, second) => { + return compareTransactionsByFeeRate(first.tx, second.tx); + }); + + for (const item of sortedItems) { + const placement = findPlacement(item, freeRects); + if (!placement) return null; + + rects.push({ + tx: item.tx, + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height + }); + + freeRects = pruneFreeRects( + freeRects.flatMap(freeRect => splitFreeRect(freeRect, placement)) + ); + } + + return rects; +} + +function quantizeTransactions(transactions, gridDimensions) { + const cellWeight = MAX_BLOCK_WEIGHT / ( + gridDimensions.columns * gridDimensions.rows + ); + return transactions.map(transaction => ({ + tx: transaction, + sideCells: Math.max( + 1, + Math.round(Math.sqrt(transaction.weight / cellWeight)) + ) + })); +} + +function compareTransactionsByFeeRate(first, second) { + if (second.feeRate !== first.feeRate) return second.feeRate - first.feeRate; + if (second.fee !== first.fee) return second.fee - first.fee; + return first.inputIndex - second.inputIndex; +} + +function rankTransactions(transactions) { + return [...transactions].sort(compareTransactionsByFeeRate); +} + +function createPlaceholderCells(rects, gridDimensions) { + const occupiedCells = new Uint8Array( + gridDimensions.columns * gridDimensions.rows + ); + + rects.forEach(rect => { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + occupiedCells[y * gridDimensions.columns + x] = 1; + } + } + }); + + const placeholderCells = []; + occupiedCells.forEach((isOccupied, index) => { + if (isOccupied) return; + placeholderCells.push({ + x: index % gridDimensions.columns, + y: Math.floor(index / gridDimensions.columns), + width: 1, + height: 1 + }); + }); + return placeholderCells; +} + +function buildScene(rects, transactions, renderedCount, gridDimensions) { + return { + rects, + placeholderCells: createPlaceholderCells(rects, gridDimensions), + transactions, + inputCount: transactions.length, + renderedCount, + gridDimensions + }; +} + +function createScene(transactions, gridDimensions) { + const rankedTransactions = rankTransactions(transactions); + const completeRects = packItems( + quantizeTransactions(rankedTransactions, gridDimensions), + gridDimensions + ); + + if (completeRects) { + return buildScene( + completeRects, + transactions, + transactions.length, + gridDimensions + ); + } + + let smallestCandidate = 0; + let largestCandidate = Math.max(0, rankedTransactions.length - 1); + let bestRects = []; + let bestCount = 0; + + while (smallestCandidate <= largestCandidate) { + const candidateCount = Math.floor( + (smallestCandidate + largestCandidate) / 2 + ); + const candidateRects = packItems( + quantizeTransactions( + rankedTransactions.slice(0, candidateCount), + gridDimensions + ), + gridDimensions + ); + + if (candidateRects) { + bestRects = candidateRects; + bestCount = candidateCount; + smallestCandidate = candidateCount + 1; + } else { + largestCandidate = candidateCount - 1; + } + } + + return buildScene( + bestRects, + transactions, + bestCount, + gridDimensions + ); +} + +function feeTierFor(transaction, feeTiers) { + if (transaction.feeRate >= feeTiers.high.threshold) return feeTiers.high; + if (transaction.feeRate >= feeTiers.medium.threshold) return feeTiers.medium; + return feeTiers.low; +} + +function easeOutCubic(progress) { + return 1 - Math.pow(1 - progress, 3); +} + +function easeInOutCubic(progress) { + return progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2; +} + +function clampProgress(elapsed, duration) { + return Math.min(1, Math.max(0, elapsed / duration)); +} + +function outgoingOpacity(progress) { + if (progress < 0.2) { + return 1 - (1 - MOVING_OPACITY) * easeOutCubic(progress / 0.2); + } + if (progress < 0.65) return MOVING_OPACITY; + return MOVING_OPACITY * ( + 1 - easeInOutCubic((progress - 0.65) / 0.35) + ); +} + +function incomingOpacity(progress) { + if (progress < 0.75) { + return MOVING_OPACITY * easeOutCubic(progress / 0.75); + } + return MOVING_OPACITY + ( + 1 - MOVING_OPACITY + ) * easeOutCubic((progress - 0.75) / 0.25); +} + +function outgoingRectState(elapsed) { + const progress = clampProgress(elapsed, EXIT_DURATION); + return { + scale: 1 - easeInOutCubic(progress), + opacity: outgoingOpacity(progress) + }; +} + +function incomingRectState(elapsed) { + const progress = clampProgress(elapsed, ENTER_DURATION); + return { + scale: ENTER_SCALE + (1 - ENTER_SCALE) * easeOutCubic(progress), + opacity: incomingOpacity(progress) + }; +} + +function formatNumber(value, fractionDigits = 0) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits + }).format(value); +} + +function shortenTxid(txid) { + return `${txid.slice(0, 8)}...${txid.slice(-8)}`; +} + +function gridDimensionsForSize(width, height, cellsPerSide) { + const safeWidth = Math.max(1, width); + const safeHeight = Math.max(1, height); + const aspectRatio = safeWidth / safeHeight; + const aspectScale = Math.sqrt(aspectRatio); + + return { + columns: Math.max(1, Math.round(cellsPerSide * aspectScale)), + rows: Math.max(1, Math.round(cellsPerSide / aspectScale)) + }; +} + +function sameGridDimensions(first, second) { + return ( + first.columns === second.columns && + first.rows === second.rows + ); +} + +export function renderBlockGrid( + elementId, + transactions, + baseUrl = "", + feeTiers, + options +) { + if (typeof elementId !== "string" || !elementId.trim()) { + throw new TypeError("renderBlockGrid requires a non-empty element ID."); + } + + const container = document.getElementById(elementId); + if (!container) { + throw new Error(`Block grid container "#${elementId}" was not found.`); + } + + if (baseUrl !== undefined && baseUrl !== null && typeof baseUrl !== "string") { + throw new TypeError("Block grid baseUrl must be a string."); + } + + const initialTransactions = normalizeTransactions(transactions); + const normalizedFeeTiers = normalizeFeeTiers(feeTiers); + const normalizedOptions = normalizeOptions(options); + const normalizedBaseUrl = (baseUrl || "").trim().replace(/\/+$/, ""); + const previousInstance = mountedInstances.get(container); + if (previousInstance) previousInstance.destroy(); + + const canvas = document.createElement("canvas"); + canvas.className = "block-grid__canvas"; + canvas.setAttribute("aria-label", "Bitcoin transaction block grid"); + canvas.style.borderColor = normalizedOptions.backgroundColor; + + const tooltip = document.createElement("div"); + tooltip.className = "block-grid__tooltip"; + tooltip.setAttribute("role", "tooltip"); + tooltip.hidden = true; + + const hadComponentClass = container.classList.contains("block-grid"); + container.classList.add("block-grid"); + container.replaceChildren(canvas, tooltip); + + const context = canvas.getContext("2d"); + if (!context) { + container.replaceChildren(); + if (!hadComponentClass) container.classList.remove("block-grid"); + throw new Error("This browser does not support the 2D canvas API."); + } + + const initialBounds = canvas.getBoundingClientRect(); + let canvasCssWidth = Math.max( + 1, + canvas.clientWidth || initialBounds.width || container.clientWidth + ); + let canvasCssHeight = Math.max( + 1, + canvas.clientHeight || + initialBounds.height || + container.clientHeight || + canvasCssWidth + ); + let gridDimensions = gridDimensionsForSize( + canvasCssWidth, + canvasCssHeight, + normalizedOptions.cellsPerSide + ); + let settledScene = createScene( + initialTransactions, + gridDimensions + ); + let hoveredTransaction = null; + let activeTransition = null; + let transitionFrameId = null; + let queuedTransactions = null; + let tooltipHideTimer = null; + let destroyed = false; + + function gridMetrics() { + const cellSize = Math.min( + canvasCssWidth / gridDimensions.columns, + canvasCssHeight / gridDimensions.rows + ); + return { + cellSize, + offsetX: ( + canvasCssWidth - gridDimensions.columns * cellSize + ) / 2, + offsetY: ( + canvasCssHeight - gridDimensions.rows * cellSize + ) / 2 + }; + } + + function gridFace(rect, metrics = gridMetrics()) { + const bounds = { + x: metrics.offsetX + rect.x * metrics.cellSize, + y: metrics.offsetY + rect.y * metrics.cellSize, + width: rect.width * metrics.cellSize, + height: rect.height * metrics.cellSize + }; + const inset = Math.min( + TILE_GUTTER / 2, + bounds.width / 4, + bounds.height / 4 + ); + return { + x: bounds.x + inset, + y: bounds.y + inset, + width: Math.max(0, bounds.width - inset * 2), + height: Math.max(0, bounds.height - inset * 2) + }; + } + + function scaledBounds(bounds, scale) { + const width = bounds.width * scale; + const height = bounds.height * scale; + return { + x: bounds.x + (bounds.width - width) / 2, + y: bounds.y + (bounds.height - height) / 2, + width, + height + }; + } + + function clearCanvas() { + context.clearRect(0, 0, canvasCssWidth, canvasCssHeight); + context.fillStyle = normalizedOptions.backgroundColor; + context.fillRect(0, 0, canvasCssWidth, canvasCssHeight); + } + + function drawPlaceholderFaces(scene) { + context.save(); + context.fillStyle = normalizedOptions.placeholderColor; + const metrics = gridMetrics(); + + scene.placeholderCells.forEach(cell => { + const bounds = gridFace(cell, metrics); + context.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + }); + + context.restore(); + } + + function drawSceneFaces(scene, rectState, allowHover = false) { + context.save(); + const metrics = gridMetrics(); + + scene.rects.forEach(rect => { + const state = rectState(rect); + if (state.scale <= 0 || state.opacity <= 0) return; + + const bounds = scaledBounds(gridFace(rect, metrics), state.scale); + if (bounds.width <= 0 || bounds.height <= 0) return; + + const isHovered = allowHover && rect.tx === hoveredTransaction; + context.globalAlpha = state.opacity * (isHovered ? 1 : 0.5); + context.fillStyle = feeTierFor(rect.tx, normalizedFeeTiers).color; + context.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + }); + + context.restore(); + } + + function drawSettledScene() { + clearCanvas(); + drawPlaceholderFaces(settledScene); + drawSceneFaces( + settledScene, + () => ({ scale: 1, opacity: 1 }), + true + ); + } + + function clearTooltipHideTimer() { + if (tooltipHideTimer !== null) window.clearTimeout(tooltipHideTimer); + tooltipHideTimer = null; + } + + function hideTooltip() { + clearTooltipHideTimer(); + tooltip.hidden = true; + if (hoveredTransaction) { + hoveredTransaction = null; + if (!activeTransition) drawSettledScene(); + } + } + + function scheduleTooltipHide() { + clearTooltipHideTimer(); + tooltipHideTimer = window.setTimeout(hideTooltip, TOOLTIP_HIDE_DELAY); + } + + function moveTooltip(event) { + const offset = 14; + const bounds = tooltip.getBoundingClientRect(); + const left = Math.min( + event.clientX + offset, + window.innerWidth - bounds.width - 8 + ); + const top = Math.min( + event.clientY + offset, + window.innerHeight - bounds.height - 8 + ); + tooltip.style.left = `${Math.max(8, left)}px`; + tooltip.style.top = `${Math.max(8, top)}px`; + } + + function showTooltip(event, transaction) { + clearTooltipHideTimer(); + tooltip.replaceChildren(); + + const definitionList = document.createElement("dl"); + const fields = [ + ["fee", `${formatNumber(transaction.fee)} sats`], + ["fee rate", `${formatNumber(transaction.feeRate, 2)} sat/vB`], + ["virtual size", `${formatNumber(transaction.virtualSize)} vB`] + ]; + const txidTerm = document.createElement("dt"); + const txidDefinition = document.createElement("dd"); + txidTerm.textContent = "txid"; + + if (normalizedBaseUrl) { + const link = document.createElement("a"); + link.href = `${normalizedBaseUrl}/${transaction.txid}`; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = shortenTxid(transaction.txid); + txidDefinition.append(link); + } else { + txidDefinition.textContent = shortenTxid(transaction.txid); + } + + definitionList.append(txidTerm, txidDefinition); + + fields.forEach(([label, value]) => { + const term = document.createElement("dt"); + const definition = document.createElement("dd"); + term.textContent = label; + definition.textContent = value; + definitionList.append(term, definition); + }); + + tooltip.append(definitionList); + tooltip.hidden = false; + moveTooltip(event); + } + + function canvasPointFromEvent(event) { + const bounds = canvas.getBoundingClientRect(); + const metrics = gridMetrics(); + const localX = ( + (event.clientX - bounds.left) * + canvasCssWidth / + bounds.width + ); + const localY = ( + (event.clientY - bounds.top) * + canvasCssHeight / + bounds.height + ); + return { + x: (localX - metrics.offsetX) / metrics.cellSize, + y: (localY - metrics.offsetY) / metrics.cellSize + }; + } + + function hitTest(x, y) { + for (let index = settledScene.rects.length - 1; index >= 0; index -= 1) { + const rect = settledScene.rects[index]; + if ( + x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height + ) { + return rect.tx; + } + } + return null; + } + + function handleCanvasPointerMove(event) { + if (activeTransition) { + canvas.style.cursor = "default"; + hideTooltip(); + return; + } + + const point = canvasPointFromEvent(event); + const transaction = hitTest(point.x, point.y); + canvas.style.cursor = transaction ? "pointer" : "default"; + + if (transaction !== hoveredTransaction) { + hoveredTransaction = transaction; + drawSettledScene(); + } + + if (transaction) { + showTooltip(event, transaction); + } else { + hideTooltip(); + } + } + + function handleCanvasPointerLeave(event) { + canvas.style.cursor = "default"; + if (event.relatedTarget && tooltip.contains(event.relatedTarget)) return; + scheduleTooltipHide(); + } + + function handleCanvasClick(event) { + if (activeTransition || !normalizedBaseUrl) return; + + const point = canvasPointFromEvent(event); + const transaction = hitTest(point.x, point.y); + if (!transaction) return; + + window.open( + `${normalizedBaseUrl}/${transaction.txid}`, + "_blank", + "noopener,noreferrer" + ); + } + + function handleTooltipPointerEnter() { + clearTooltipHideTimer(); + } + + function handleTooltipPointerLeave(event) { + if (event.relatedTarget === canvas) return; + hideTooltip(); + } + + function resizeCanvas() { + const bounds = canvas.getBoundingClientRect(); + const nextCssWidth = Math.max( + 1, + canvas.clientWidth || bounds.width || container.clientWidth + ); + const nextCssHeight = Math.max( + 1, + canvas.clientHeight || + bounds.height || + container.clientHeight || + nextCssWidth + ); + const pixelRatio = Math.max(1, window.devicePixelRatio || 1); + const pixelWidth = Math.max(1, Math.round(nextCssWidth * pixelRatio)); + const pixelHeight = Math.max(1, Math.round(nextCssHeight * pixelRatio)); + const nextGridDimensions = gridDimensionsForSize( + nextCssWidth, + nextCssHeight, + normalizedOptions.cellsPerSide + ); + const gridDimensionsChanged = !sameGridDimensions( + gridDimensions, + nextGridDimensions + ); + + canvasCssWidth = nextCssWidth; + canvasCssHeight = nextCssHeight; + gridDimensions = nextGridDimensions; + if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { + canvas.width = pixelWidth; + canvas.height = pixelHeight; + } + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + return gridDimensionsChanged; + } + + function repackForGridDimensions() { + clearTooltipHideTimer(); + tooltip.hidden = true; + hoveredTransaction = null; + canvas.style.cursor = "default"; + + if (activeTransition) { + activeTransition.from = createScene( + activeTransition.from.transactions, + gridDimensions + ); + activeTransition.to = createScene( + activeTransition.to.transactions, + gridDimensions + ); + settledScene = activeTransition.from; + return; + } + + settledScene = createScene(settledScene.transactions, gridDimensions); + } + + function drawTransition(timestamp) { + if (!activeTransition) return false; + if (activeTransition.startedAt === null) { + activeTransition.startedAt = timestamp; + } + + const elapsed = Math.min( + timestamp - activeTransition.startedAt, + TRANSITION_DURATION + ); + clearCanvas(); + + if (elapsed < EXIT_DURATION) { + drawPlaceholderFaces(activeTransition.from); + drawSceneFaces( + activeTransition.from, + () => outgoingRectState(elapsed) + ); + } else { + drawPlaceholderFaces(activeTransition.to); + } + + if (elapsed >= ENTER_START) { + drawSceneFaces( + activeTransition.to, + () => incomingRectState(elapsed - ENTER_START) + ); + } + + return elapsed >= TRANSITION_DURATION; + } + + function cancelTransition() { + if (transitionFrameId !== null) { + window.cancelAnimationFrame(transitionFrameId); + } + transitionFrameId = null; + activeTransition = null; + queuedTransactions = null; + } + + function startTransition(nextScene) { + hideTooltip(); + canvas.style.cursor = "default"; + activeTransition = { + from: settledScene, + to: nextScene, + startedAt: null + }; + transitionFrameId = window.requestAnimationFrame(stepTransition); + } + + function finishTransition() { + settledScene = activeTransition.to; + activeTransition = null; + transitionFrameId = null; + drawSettledScene(); + + if (queuedTransactions) { + const nextTransactions = queuedTransactions; + queuedTransactions = null; + startTransition( + createScene(nextTransactions, gridDimensions) + ); + } + } + + function stepTransition(timestamp) { + if (!activeTransition) return; + if (drawTransition(timestamp)) { + finishTransition(); + return; + } + transitionFrameId = window.requestAnimationFrame(stepTransition); + } + + function handleResize() { + if (destroyed) return; + const gridDimensionsChanged = resizeCanvas(); + if (gridDimensionsChanged) repackForGridDimensions(); + if (activeTransition) { + drawTransition(window.performance.now()); + } else { + drawSettledScene(); + } + } + + canvas.addEventListener("pointermove", handleCanvasPointerMove); + canvas.addEventListener("pointerleave", handleCanvasPointerLeave); + canvas.addEventListener("click", handleCanvasClick); + tooltip.addEventListener("pointerenter", handleTooltipPointerEnter); + tooltip.addEventListener("pointerleave", handleTooltipPointerLeave); + window.addEventListener("resize", handleResize); + + const resizeObserver = typeof ResizeObserver === "function" + ? new ResizeObserver(handleResize) + : null; + if (resizeObserver) resizeObserver.observe(container); + + const handle = { + update(nextTransactions) { + if (destroyed) { + throw new Error("Cannot update a destroyed block grid."); + } + + const normalizedTransactions = normalizeTransactions(nextTransactions); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + cancelTransition(); + hideTooltip(); + settledScene = createScene( + normalizedTransactions, + gridDimensions + ); + drawSettledScene(); + return; + } + + if (activeTransition) { + queuedTransactions = normalizedTransactions; + return; + } + + startTransition( + createScene(normalizedTransactions, gridDimensions) + ); + }, + + destroy() { + if (destroyed) return; + destroyed = true; + cancelTransition(); + clearTooltipHideTimer(); + resizeObserver?.disconnect(); + window.removeEventListener("resize", handleResize); + canvas.removeEventListener("pointermove", handleCanvasPointerMove); + canvas.removeEventListener("pointerleave", handleCanvasPointerLeave); + canvas.removeEventListener("click", handleCanvasClick); + tooltip.removeEventListener("pointerenter", handleTooltipPointerEnter); + tooltip.removeEventListener("pointerleave", handleTooltipPointerLeave); + + if (mountedInstances.get(container) === handle) { + mountedInstances.delete(container); + container.replaceChildren(); + if (!hadComponentClass) container.classList.remove("block-grid"); + } + } + }; + + mountedInstances.set(container, handle); + handleResize(); + return handle; +} diff --git a/client/src/const.js b/client/src/const.js index b173552bb..858f80d8f 100644 --- a/client/src/const.js +++ b/client/src/const.js @@ -6,6 +6,7 @@ export const maxMempoolTxs = 50 export const satoshisPerBitcoin = 100000000 export const averageNativeSegwitTransactionSize = 140 export const maxBlockWeight = 4000000 +export const blockGridLoadingDelayMs = 100 const configuredTargetBlockIntervalSeconds = Number(process.env.TARGET_BLOCK_INTERVAL_SECONDS) export const targetBlockIntervalSeconds = configuredTargetBlockIntervalSeconds > 0 diff --git a/client/src/lib/block-template.js b/client/src/lib/block-template.js new file mode 100644 index 000000000..62385cf28 --- /dev/null +++ b/client/src/lib/block-template.js @@ -0,0 +1,118 @@ +import { maxBlockWeight } from "../const"; +import { feeRateClass } from "./fees"; + +const percentage = (value, limit) => + Number.isFinite(value) && Number.isFinite(limit) && limit > 0 + ? Math.min(Math.max((value / limit) * 100, 0), 100) + : null; + +const transactionVsize = (tx) => + tx && Number.isFinite(tx.weight) && tx.weight > 0 + ? Math.ceil(tx.weight / 4) + : null; + +const transactionSize = (tx) => + tx && typeof tx.data === "string" && tx.data.length % 2 === 0 + ? tx.data.length / 2 + : null; + +const isSegwitTransaction = (tx) => + tx && + typeof tx.data === "string" && + tx.data.length >= 12 && + tx.data.slice(8, 10) === "00" && + tx.data.slice(10, 12) !== "00"; + +const sumCompleteValues = (values) => + values.every(Number.isFinite) + ? values.reduce((sum, value) => sum + value, 0) + : null; + +const summarizeFeeBucket = (transactions) => { + if (!transactions.length) { + return { count: 0, averageFeeRate: null, averageFee: null }; + } + + const totalFees = transactions.reduce((sum, tx) => sum + tx.fee, 0); + const totalVsize = transactions.reduce((sum, tx) => sum + tx.vsize, 0); + + return { + count: transactions.length, + averageFeeRate: totalVsize > 0 ? totalFees / totalVsize : null, + averageFee: totalFees / transactions.length, + }; +}; + +const summarizeFeeBuckets = (transactions, feeEst) => { + if (!feeEst || feeEst[3] == null || feeEst[12] == null) { + return { low: null, medium: null, high: null }; + } + + const buckets = { success: [], warning: [], danger: [] }; + + transactions.forEach((tx) => { + if (!Number.isFinite(tx.fee) || !Number.isFinite(tx.vsize)) return; + + const className = feeRateClass(tx.fee / tx.vsize, feeEst); + if (buckets[className]) buckets[className].push(tx); + }); + + return { + low: summarizeFeeBucket(buckets.success), + medium: summarizeFeeBucket(buckets.warning), + high: summarizeFeeBucket(buckets.danger), + }; +}; + +export const summarizeBlockTemplate = (template, feeEst) => { + if (!template || !Array.isArray(template.transactions)) return null; + + const transactions = template.transactions.map((tx) => ({ + ...tx, + vsize: transactionVsize(tx), + size: transactionSize(tx), + })); + const fees = transactions.map((tx) => tx.fee); + const weights = transactions.map((tx) => tx.weight); + const sizes = transactions.map((tx) => tx.size); + const vsizes = transactions.map((tx) => tx.vsize); + const totalFees = sumCompleteValues(fees); + const totalWeight = sumCompleteValues(weights); + const totalSize = sumCompleteValues(sizes); + const totalVsize = sumCompleteValues(vsizes); + const weightLimit = Number.isFinite(template.weightlimit) + ? template.weightlimit + : maxBlockWeight; + const sizeLimit = Number.isFinite(template.sizelimit) + ? template.sizelimit + : null; + const hasCompleteTransactionData = sizes.every(Number.isFinite); + const segwitCount = hasCompleteTransactionData + ? transactions.filter(isSegwitTransaction).length + : null; + + return { + height: Number.isFinite(template.height) ? template.height : null, + updatedAt: Number.isFinite(template.curtime) ? template.curtime : null, + templateTransactionCount: transactions.length, + transactionCount: transactions.length + 1, + totalFees, + totalWeight, + totalSize, + weightLimit, + sizeLimit, + weightPercentage: percentage(totalWeight, weightLimit), + sizePercentage: percentage(totalSize, sizeLimit), + averageFeeRate: + Number.isFinite(totalFees) && Number.isFinite(totalVsize) && totalVsize > 0 + ? totalFees / totalVsize + : null, + feeBuckets: summarizeFeeBuckets(transactions, feeEst), + segwitCount, + segwitPercentage: hasCompleteTransactionData + ? transactions.length + ? percentage(segwitCount, transactions.length) + : 0 + : null, + }; +}; diff --git a/client/src/lib/fees.js b/client/src/lib/fees.js index 6ea966d07..525867fdc 100644 --- a/client/src/lib/fees.js +++ b/client/src/lib/fees.js @@ -1,5 +1,15 @@ const MAX_BLOCK_VSIZE = 1000000 +export const feeRateClass = (feerate, feeEst) => { + if (!feeEst || feeEst[3] == null || feeEst[12] == null) return "" + + return feerate <= feeEst[12] + ? "success" + : feerate <= feeEst[3] + ? "warning" + : "danger" +} + // Squash fee buckets into fixed fee-rates ranges, with steps of 50% (1, 1.5, 2.25, ..) const SQUASH_BUCKETS = Array.from(Array(20)).map((_,i) => 1*Math.pow(1.5, i)).reverse().concat(0) diff --git a/client/src/lib/mempool.js b/client/src/lib/mempool.js new file mode 100644 index 000000000..cb33b6832 --- /dev/null +++ b/client/src/lib/mempool.js @@ -0,0 +1,21 @@ +const DEFAULT_MEMPOOL_LIMIT_BYTES = 300 * 1000 * 1000; + +export const getMempoolUsage = (mempool) => + mempool && Number.isFinite(mempool.vsize) + ? Math.max(0, Math.min(1, mempool.vsize / DEFAULT_MEMPOOL_LIMIT_BYTES)) + : 0; + +export const getMempoolCongestionLevel = (usage) => { + if (usage < 1 / 3) return "Low"; + if (usage < 2 / 3) return "Moderate"; + return "High"; +}; + +const congestionClassByLevel = { + Low: "success", + Moderate: "warning", + High: "danger", +}; + +export const getMempoolCongestionClass = (level) => + congestionClassByLevel[level] || ""; diff --git a/client/src/views/blocks.js b/client/src/views/blocks.js index c18bade8a..42f62872f 100644 --- a/client/src/views/blocks.js +++ b/client/src/views/blocks.js @@ -8,6 +8,7 @@ import loader from "../components/loading"; import { BlockIcon, ClockIcon, CopyIcon } from "../components/icons"; import { InfoStat } from "../components/info-stat"; import { Tooltip } from "../components/tooltip"; +import PendingBlockDetailsCard from "./pending-block-details-card"; const staticRoot = process.env.STATIC_ROOT || ""; @@ -25,6 +26,29 @@ export const blks = (blocks, viewMore, { t, ...S }) => (

Latest Blocks

+ + {viewMore ? ( + + ) : ( + "" + )} + + {viewMore ? ( + + ) : ( + "" + )} + {viewMore ?

Blocks History

: ""} +
{blocks && blocks.map((b, index) => ( diff --git a/client/src/views/overview.js b/client/src/views/overview.js index a20fe305a..3da803aa2 100644 --- a/client/src/views/overview.js +++ b/client/src/views/overview.js @@ -5,9 +5,13 @@ import { import { ElapsedTime } from "../components/elapsed-time"; import { InfoCard } from "../components/info-card"; import { ReferenceLineChart } from "../components/reference-line-chart"; +import { + getMempoolCongestionClass, + getMempoolCongestionLevel, + getMempoolUsage, +} from "../lib/mempool"; const staticRoot = process.env.STATIC_ROOT || ""; -const DEFAULT_MEMPOOL_LIMIT_BYTES = 300 * 1000 * 1000; const getBitcoinPrices = (marketChart) => ((marketChart && marketChart.prices) || []) @@ -37,38 +41,6 @@ const estimateNativeSegwitFeeUsd = (bitcoinPrice, feeEst) => ) : ""; -const getMempoolUsage = (mempool) => - mempool && Number.isFinite(mempool.vsize) - ? Math.max(0, Math.min(1, mempool.vsize / DEFAULT_MEMPOOL_LIMIT_BYTES)) - : 0; - -const MEMPOOL_CONGESTION_LEVEL = { - LOW: "Low", - MODERATE: "Moderate", - HIGH: "High", -}; - -const getMempoolCongestionLevel = (usage) => { - if (usage < 1 / 3) { - return MEMPOOL_CONGESTION_LEVEL.LOW; - } - - if (usage < 2 / 3) { - return MEMPOOL_CONGESTION_LEVEL.MODERATE; - } - - return MEMPOOL_CONGESTION_LEVEL.HIGH; -}; - -const CONGESTION_CLASS_BY_LEVEL = { - [MEMPOOL_CONGESTION_LEVEL.LOW]: "success", - [MEMPOOL_CONGESTION_LEVEL.MODERATE]: "warning", - [MEMPOOL_CONGESTION_LEVEL.HIGH]: "danger", -}; - -const getMempoolCongestionClass = (level) => - CONGESTION_CLASS_BY_LEVEL[level] || ""; - const getLatestPrice = (marketChart) => { const prices = getBitcoinPrices(marketChart); return prices.length ? prices[prices.length - 1] : null; diff --git a/client/src/views/pending-block-details-card.js b/client/src/views/pending-block-details-card.js new file mode 100644 index 000000000..3960e3bb5 --- /dev/null +++ b/client/src/views/pending-block-details-card.js @@ -0,0 +1,735 @@ +import { BlockGrid } from "../components/block-grid"; +import { ElapsedTime } from "../components/elapsed-time"; +import { PlusIcon, MinusIcon } from "../components/icons"; +import { InfoCard } from "../components/info-card"; +import { StatusBadge } from "../components/status-badge"; +import { renderBlockGrid } from "../components/transaction-block-grid"; +import { blockGridLoadingDelayMs, satoshisPerBitcoin } from "../const"; +import { summarizeBlockTemplate } from "../lib/block-template"; +import { + getMempoolCongestionClass, + getMempoolCongestionLevel, + getMempoolUsage, +} from "../lib/mempool"; +import { formatSat, formatVMB } from "./util"; + +const BLOCK_TARGET_SECONDS = (process.env.IS_ELEMENTS ? 1 : 10) * 60; +const ESTIMATE_UPDATE_INTERVAL_MS = 60 * 1000; +const EMPTY_TRANSACTIONS = []; + +const nextFeeRate = (value) => + value + Math.max(1, Math.abs(value)) * Number.EPSILON * 4; + +const getFeeTierBoundaries = (feeEst) => { + const low = feeEst && feeEst[12]; + const high = feeEst && feeEst[3]; + + if ( + !Number.isFinite(low) || + !Number.isFinite(high) || + low < 0 || + high < 0 + ) { + return null; + } + + return { + low, + high: Math.max(low, high), + }; +}; + +const getBlockGridConfig = (element, boundaries) => { + if (!boundaries || typeof window === "undefined") return null; + + const styles = window.getComputedStyle(element); + const cssValue = (property) => styles.getPropertyValue(property).trim(); + const rgbColor = (property) => `rgb(${cssValue(property)})`; + const mediumThreshold = nextFeeRate(boundaries.low); + const highThreshold = nextFeeRate( + Math.max(boundaries.high, mediumThreshold), + ); + const feeTiers = { + low: { + threshold: 0, + color: rgbColor("--success-color-rgb"), + }, + medium: { + threshold: mediumThreshold, + color: rgbColor("--warning-color-rgb"), + }, + high: { + threshold: highThreshold, + color: rgbColor("--danger-color-rgb"), + }, + }; + const options = { + backgroundColor: cssValue("--surface-primary-color"), + placeholderColor: cssValue("--surface-secondary-color"), + }; + + return { + feeTiers, + options, + key: JSON.stringify({ feeTiers, options }), + }; +}; + +const showPendingBlockGridLoading = (element) => { + const loading = document.createElement("div"); + const wave = document.createElement("div"); + + loading.className = "pending-block-grid-loading"; + loading.setAttribute("role", "status"); + loading.setAttribute("aria-label", "Loading pending block transactions"); + wave.className = "pending-block-grid-loading-wave"; + wave.setAttribute("aria-hidden", "true"); + + for (let index = 0; index < 25; index += 1) { + const cell = document.createElement("span"); + const row = Math.floor(index / 5); + const column = index % 5; + cell.style.animationDelay = `${(row + column) * 70}ms`; + wave.append(cell); + } + + loading.append(wave); + element.classList.add("block-grid", "pending-block-grid-is-loading"); + element.replaceChildren(loading); +}; + +const clearPendingBlockGridLoadingTimer = (gridState) => { + if (gridState.loadingTimer !== null) { + window.clearTimeout(gridState.loadingTimer); + gridState.loadingTimer = null; + } +}; + +const schedulePendingBlockGridLoading = ( + element, + gridState, + loadingDelayMs, +) => { + clearPendingBlockGridLoadingTimer(gridState); + gridState.loadingTimer = window.setTimeout(() => { + gridState.loadingTimer = null; + if (!gridState.destroyed && !gridState.handle) { + showPendingBlockGridLoading(element); + } + }, loadingDelayMs); +}; + +const cancelPendingBlockGridWork = (gridState) => { + if (gridState.paintFrame !== null) { + window.cancelAnimationFrame(gridState.paintFrame); + } + if (gridState.workFrame !== null) { + window.cancelAnimationFrame(gridState.workFrame); + } + gridState.paintFrame = null; + gridState.workFrame = null; +}; + +const queuePendingBlockGridWork = ( + element, + gridState, + transactions, + config, +) => { + gridState.targetTransactions = transactions; + gridState.targetConfig = config; + if (gridState.paintFrame !== null || gridState.workFrame !== null) return; + + gridState.paintFrame = window.requestAnimationFrame(() => { + gridState.paintFrame = null; + gridState.workFrame = window.requestAnimationFrame(() => { + gridState.workFrame = null; + if (gridState.destroyed) return; + + const nextConfig = gridState.targetConfig; + const nextTransactions = gridState.targetTransactions; + if (!nextConfig) return; + + if (!gridState.handle || gridState.configKey !== nextConfig.key) { + const transactionBaseUrl = new URL("tx", document.baseURI).href; + gridState.handle = renderBlockGrid( + "pending-block", + nextTransactions, + transactionBaseUrl, + nextConfig.feeTiers, + nextConfig.options, + ); + gridState.configKey = nextConfig.key; + gridState.transactions = nextTransactions; + } else if (gridState.transactions !== nextTransactions) { + gridState.handle.update(nextTransactions); + gridState.transactions = nextTransactions; + } + + clearPendingBlockGridLoadingTimer(gridState); + element.classList.remove("pending-block-grid-is-loading"); + }); + }); +}; + +const mountPendingBlockGrid = ( + vnode, + transactions, + boundaries, + loadingDelayMs = blockGridLoadingDelayMs, +) => { + const gridState = { + configKey: null, + destroyed: false, + handle: null, + loadingTimer: null, + paintFrame: null, + targetConfig: null, + targetTransactions: transactions, + transactions: null, + workFrame: null, + }; + + vnode.elm.pendingBlockGrid = gridState; + vnode.elm.classList.add("block-grid"); + schedulePendingBlockGridLoading( + vnode.elm, + gridState, + loadingDelayMs, + ); + + const config = getBlockGridConfig(vnode.elm, boundaries); + if (config) { + queuePendingBlockGridWork(vnode.elm, gridState, transactions, config); + } +}; + +const patchPendingBlockGrid = (_, vnode, transactions, boundaries) => { + const gridState = vnode.elm.pendingBlockGrid; + const config = getBlockGridConfig(vnode.elm, boundaries); + + if (!gridState || !config) return; + if ( + gridState.targetConfig && + gridState.targetConfig.key === config.key && + gridState.targetTransactions === transactions + ) { + return; + } + + queuePendingBlockGridWork(vnode.elm, gridState, transactions, config); +}; + +const destroyPendingBlockGrid = (vnode) => { + const gridState = vnode.elm.pendingBlockGrid; + if (!gridState) return; + + gridState.destroyed = true; + clearPendingBlockGridLoadingTimer(gridState); + cancelPendingBlockGridWork(gridState); + if (gridState.handle) gridState.handle.destroy(); + vnode.elm.pendingBlockGrid = null; +}; + +const formatEstimatedBlockTime = (timestamp) => { + if (!Number.isFinite(timestamp)) return "N/A"; + + const elapsedSeconds = Math.max(0, Date.now() / 1000 - timestamp); + const estimatedMinutes = Math.max( + 1, + Math.ceil((BLOCK_TARGET_SECONDS - elapsedSeconds) / 60), + ); + + return `IN ~${estimatedMinutes} ${ + estimatedMinutes === 1 ? "MINUTE" : "MINUTES" + }`; +}; + +const updateEstimatedBlockTime = (element) => { + element.textContent = formatEstimatedBlockTime( + element.estimatedBlockTimestamp, + ); +}; + +const startEstimatedBlockTime = (vnode, timestamp) => { + vnode.elm.estimatedBlockTimestamp = timestamp; + updateEstimatedBlockTime(vnode.elm); + vnode.elm.estimatedBlockInterval = window.setInterval( + () => updateEstimatedBlockTime(vnode.elm), + ESTIMATE_UPDATE_INTERVAL_MS, + ); +}; + +const patchEstimatedBlockTime = (_, vnode, timestamp) => { + vnode.elm.estimatedBlockTimestamp = timestamp; + updateEstimatedBlockTime(vnode.elm); +}; + +const stopEstimatedBlockTime = (vnode) => { + window.clearInterval(vnode.elm.estimatedBlockInterval); +}; + +const EstimatedBlockTime = ({ timestamp }) => ( + startEstimatedBlockTime(vnode, timestamp)} + hook-postpatch={(oldVnode, vnode) => + patchEstimatedBlockTime(oldVnode, vnode, timestamp) + } + hook-destroy={stopEstimatedBlockTime} + > + {formatEstimatedBlockTime(timestamp)} + +); + +const formatPercentage = (value, fallback = "N/A") => + Number.isFinite(value) ? `${value.toFixed(2)}%` : fallback; + +const formatFeeRate = (value, fallback = "N/A") => + Number.isFinite(value) ? `${value.toFixed(2)} sat/vB` : fallback; + +const formatFeeBoundary = (value, fallback = "N/A") => + Number.isFinite(value) ? value.toFixed(2) : fallback; + +const formatWeight = (value, fallback = "N/A") => + Number.isFinite(value) + ? `${(value / 1_000_000).toFixed(2)} MWU` + : fallback; + +const formatCount = (value, fallback = "N/A") => + Number.isFinite(value) ? value.toLocaleString() : fallback; + +const formatBitcoin = (sats, fallback = "N/A") => + Number.isFinite(sats) + ? `${(sats / satoshisPerBitcoin).toFixed(8)} BTC` + : fallback; + +const getLatestBitcoinPrice = (marketChart) => { + const prices = ((marketChart && marketChart.prices) || []) + .map((price) => price && price[1]) + .filter(Number.isFinite); + + return prices.length ? prices[prices.length - 1] : null; +}; + +const formatUsd = (value, fallback = "N/A") => + Number.isFinite(value) + ? `$${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD` + : fallback; + +const formatFeeCost = (sats, bitcoinPrice, fallback = "N/A") => { + if (!Number.isFinite(sats) || !Number.isFinite(bitcoinPrice)) { + return fallback; + } + + const usdValue = (sats / satoshisPerBitcoin) * bitcoinPrice; + return `${formatBitcoin(sats, fallback)} / ${formatUsd(usdValue, fallback)}`; +}; + +const blockStat = (title, value) => ( +
+

{title}

+

{value}

+
+); + +const bar = ({ + className, + title, + headerValue, + fillPercentage, + barFillClass, + footer, +}) => ( +
+
+ {title !== undefined ?
{title}
: null} + {headerValue !== undefined ? ( +
{headerValue}
+ ) : null} +
+
+
+
+
{footer}
+
+); + +const detailPanel = (className, title, value, footer) => ( + +); + +const feeBucketPanel = ( + className, + title, + bucket, + bitcoinPrice, + valueFallback, + costFallback, +) => + detailPanel( + className, + title, + bucket + ? formatFeeRate(bucket.averageFeeRate, valueFallback) + : valueFallback, + bucket + ? formatFeeCost(bucket.averageFee, bitcoinPrice, costFallback) + : valueFallback, + ); + +const PendingBlockDetailsCard = ({ + bitcoinMarketChart, + block, + blockTemplate, + detailsOpen, + feeEst, + mempool, +}) => { + const templateFallback = blockTemplate ? "N/A" : "-"; + const feeEstimateFallback = feeEst ? "N/A" : "-"; + const mempoolFallback = mempool ? "N/A" : "-"; + const feeBucketFallback = + blockTemplate && feeEst ? "N/A" : "-"; + const feeCostFallback = + blockTemplate && feeEst && bitcoinMarketChart ? "N/A" : "-"; + const totalFeesUsdFallback = + blockTemplate && bitcoinMarketChart ? "N/A" : "-"; + const metrics = summarizeBlockTemplate(blockTemplate, feeEst); + const gridTransactions = + blockTemplate && Array.isArray(blockTemplate.transactions) + ? blockTemplate.transactions + : EMPTY_TRANSACTIONS; + const feeTierBoundaries = getFeeTierBoundaries(feeEst); + const blockGridBoundaries = + blockTemplate && Array.isArray(blockTemplate.transactions) + ? feeTierBoundaries + : null; + const bitcoinPrice = getLatestBitcoinPrice(bitcoinMarketChart); + const weightPercentage = metrics && metrics.weightPercentage; + const mempoolUsage = getMempoolUsage(mempool); + const mempoolUsagePercentage = mempool ? mempoolUsage * 100 : null; + const mempoolCongestionLevel = mempool + ? getMempoolCongestionLevel(mempoolUsage) + : ""; + const mempoolCongestionClass = getMempoolCongestionClass( + mempoolCongestionLevel, + ); + + return ( +
+
+ + +
+
+

Next Block

+ +

+ {block ? ( + + ) : ( + "-" + )} + + + Mining... + +

+ +
+ +
+ {blockStat( + "AVG FEE", + formatFeeRate( + metrics && metrics.averageFeeRate, + templateFallback, + ), + )} + {blockStat( + "TRANSACTIONS", + formatCount( + metrics && metrics.transactionCount, + templateFallback, + ), + )} + {blockStat( + "SIZE", + metrics && Number.isFinite(metrics.totalSize) + ? formatVMB(metrics.totalSize, "MB") + : templateFallback, + )} + {blockStat( + "TOTAL FEE COLLECTED", + metrics && Number.isFinite(metrics.totalFees) + ? formatSat(metrics.totalFees) + : templateFallback, + )} +
+ +
+
+

Block filling

+

+ {formatPercentage(weightPercentage, templateFallback)} +

+
+ +
+
+
+ +

+ {metrics + ? `${formatWeight(metrics.totalWeight)} / ${formatWeight(metrics.weightLimit)}` + : templateFallback} +

+
+
+
+ + {detailsOpen ? ( +
+
+
+
+ mountPendingBlockGrid( + vnode, + gridTransactions, + blockGridBoundaries, + ) + } + hook-postpatch={(oldVnode, vnode) => + patchPendingBlockGrid( + oldVnode, + vnode, + gridTransactions, + blockGridBoundaries, + ) + } + hook-destroy={destroyPendingBlockGrid} + >
+
+
+
+
+

+ Low ( + {feeTierBoundaries + ? `≤${formatFeeBoundary(feeTierBoundaries.low)} sat/vB` + : feeEstimateFallback} + ) +

+
+
+
+

+ Medium ( + {feeTierBoundaries + ? `${formatFeeBoundary(feeTierBoundaries.low)}–${formatFeeBoundary(feeTierBoundaries.high)} sat/vB` + : feeEstimateFallback} + ) +

+
+
+
+

+ High ( + {feeTierBoundaries + ? `>${formatFeeBoundary(feeTierBoundaries.high)} sat/vB` + : feeEstimateFallback} + ) +

+
+
+
+
+
+ {detailPanel( + "time-since-last-block", + "Time Since Last Block", + block ? ( + + ) : ( + "-" + ), + block ? `Block #${block.height.toLocaleString()}` : "-", + )} + {detailPanel( + "block-transactions", + "Transactions", + formatCount( + metrics && metrics.transactionCount, + templateFallback, + ), + metrics + ? `${formatCount(metrics.templateTransactionCount)} SELECTED + COINBASE` + : templateFallback, + )} +
+
+ {feeBucketPanel( + "low-fee", + "Low", + metrics && metrics.feeBuckets.low, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + )} + {feeBucketPanel( + "avg-fee", + "Medium", + metrics && metrics.feeBuckets.medium, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + )} + {feeBucketPanel( + "high-fee", + "High", + metrics && metrics.feeBuckets.high, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + )} +
+
+ {detailPanel( + "total-fees-collected", + "Total Fees Collected", + metrics && Number.isFinite(metrics.totalFees) + ? formatSat(metrics.totalFees) + : templateFallback, + metrics && + Number.isFinite(metrics.totalFees) && + Number.isFinite(bitcoinPrice) + ? formatUsd( + (metrics.totalFees / satoshisPerBitcoin) * bitcoinPrice, + ) + : totalFeesUsdFallback, + )} +
+
+ + {bar({ + title: "SegWit", + headerValue: metrics + ? `${formatPercentage(metrics.segwitPercentage)} · ${formatCount(metrics.segwitCount)}/${formatCount(metrics.templateTransactionCount)} TX` + : templateFallback, + fillPercentage: metrics && metrics.segwitPercentage, + barFillClass: "block-weight-segwit-bar-fill", + })} + {bar({ + title: "Taproot", + headerValue: "N/A", + fillPercentage: null, + barFillClass: "block-weight-taproot-bar-fill", + })} +
+ } + /> + + + {bar({ + title: "Weight", + headerValue: metrics + ? `${formatWeight(metrics.totalWeight)} / ${formatWeight(metrics.weightLimit)}` + : templateFallback, + fillPercentage: metrics && metrics.weightPercentage, + barFillClass: "block-weight-weight-bar", + })} + {bar({ + title: "Size", + headerValue: + metrics && + Number.isFinite(metrics.totalSize) && + Number.isFinite(metrics.sizeLimit) + ? `${formatVMB(metrics.totalSize, "MB")} / ${formatVMB(metrics.sizeLimit, "MB")}` + : templateFallback, + fillPercentage: metrics && metrics.sizePercentage, + barFillClass: "block-weight-size-bar", + })} +
+ } + /> +
+
+ {detailPanel( + "pending-transactions", + "Pending Transactions", + formatCount(mempool && mempool.count, mempoolFallback), + mempool ? "IN MEMPOOL" : mempoolFallback, + )} + + + {bar({ + title: "Usage", + headerValue: mempool + ? `${mempoolCongestionLevel} · ${formatPercentage(mempoolUsagePercentage)}` + : mempoolFallback, + fillPercentage: mempoolUsagePercentage, + barFillClass: `mempool-congestion-fill ${mempoolCongestionClass}`, + })} +
+ } + /> +
+
+ + ) : ( + "" + )} + + ); +}; + +export default PendingBlockDetailsCard; diff --git a/client/src/views/transactions.js b/client/src/views/transactions.js index eebf48e78..3d4f90ea4 100644 --- a/client/src/views/transactions.js +++ b/client/src/views/transactions.js @@ -2,19 +2,10 @@ import { formatSat, formatNumber, truncateTxid } from "./util"; import loader from "../components/loading"; import { CopyIcon, TxArrowsIcon } from "../components/icons"; import { ConfidentialBadge } from "../components/status-badge"; +import { feeRateClass } from "../lib/fees"; const staticRoot = process.env.STATIC_ROOT || ""; -const feeRateClass = (feerate, feeEst) => { - if (!feeEst || feeEst[3] == null || feeEst[12] == null) return ""; - - return feerate <= feeEst[12] - ? "success" - : feerate <= feeEst[3] - ? "warning" - : "danger"; -} - export const transactions = (txs, viewMore, { t, ...S }) => (
{!txs ? ( diff --git a/www/style.css b/www/style.css index 698639f9f..12c9ec8e0 100644 --- a/www/style.css +++ b/www/style.css @@ -3988,6 +3988,12 @@ a.back-link img{ color: var(--danger-color); } +.mining-status-badge .confirmation-status-dot-front, +.mining-status-badge .confirmation-status-dot-middle, +.mining-status-badge .confirmation-status-dot-back { + background-color: rgba(var(--success-color-rgb), 1); +} + .eta-label { height: fit-content; border-radius: 4px; @@ -4321,6 +4327,7 @@ a.back-link img{ /* These variables are pulled by the BlockGrid component */ --block-grid-empty-color: var(--surface-secondary-color); --block-grid-filled-color: var(--accent-color); + position: relative; flex: 0 0 auto; padding: 10px; border: 2px solid rgba(var(--accent-color-rgb), .2); @@ -4335,6 +4342,22 @@ a.back-link img{ height: 148px; } +.block-details-card-grid-loading { + position: absolute; + display: grid; + inset: 10px; + opacity: 0; + pointer-events: none; + animation: block-details-card-grid-loading-reveal 0s linear forwards; + place-items: center; +} + +@keyframes block-details-card-grid-loading-reveal { + to { + opacity: 1; + } +} + .block-details-card-content { display: flex; flex-direction: column; @@ -4670,6 +4693,510 @@ a.back-link img{ .asset-table-issuance-link { margin-left: 0; } +} + +.pending-block-details-card { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + margin-top: 24px; + border-radius: 4px; +} + +.pending-block-details-card-summary { + display: flex; +} + +.pending-block-details { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + margin-left: 16px; +} + +.pending-block-details-card-header { + display: flex; + gap: 8px; + align-items: center; + min-height: 20px; +} + +.pending-block-timestamp { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--foreground-muted); + font-size: 14px; + font-weight: 400; +} + +.pending-block-details-button { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 4px; + border: 0; + color: var(--foreground-link-color); + background: none; + font-family: "Rigid Square", sans-serif; + font-size: 16px; + font-weight: 700; + cursor: pointer; +} + +.pending-block-details-button svg { + width: 14px; + height: 14px; +} + +.pending-block-details-button:focus-visible { + border-radius: 2px; + outline: 2px solid var(--accent-color); + outline-offset: 3px; +} + +.pending-block-stats { + display: flex; + flex-wrap: wrap; + gap: 24px; + margin-top: 12px; +} + +.pending-block-stat { + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 24px; + border-right: 1px solid #434445; +} + +.pending-block-stat:last-child { + padding-right: 0; + border-right: 0; +} + +.pending-block-stat-header, +.pending-block-stat-value { + margin: 0; +} + +.pending-block-stat-header { + color: var(--foreground-muted); + font-size: 10px; +} + +.pending-block-stat-value { + font-size: 14px; +} + +.pending-block-progress { + margin-top: auto; +} + +.pending-block-filling { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--foreground-muted); + font-size: 11px; +} + +.pending-block-filling p { + margin-bottom: 0; +} + +.pending-block-filling .usage-number { + color: #fff; +} + +.pending-block-usage-bar { + position: relative; + height: 10px; + margin-top: 6px; + overflow: hidden; + border-radius: 13px; + background-color: var(--surface-primary-color); +} + +.pending-block-usage-bar-fill { + height: 100%; + border-radius: 13px; + background-image: linear-gradient(90deg, #22C55E 0%, #FA3600 100%); + background-repeat: no-repeat; +} + +.pending-block-target { + margin: 4px 0 0; + color: var(--foreground-muted); + font-size: 11px; + text-align: right; +} + +.expanded-pending-block-details { + display: flex; + margin-top: 32px; +} + +.expanded-pending-block-grid-container { + display: flex; + flex: .45; + flex-direction: column; + min-width: 0; + padding-right: 16px; +} + +.pending-block-container { + flex: 1; + min-height: 0; + padding: 8px; + border: 2px solid rgba(var(--accent-color-rgb), .2); + border-radius: 4px; +} + +.pending-block.block-grid { + width: 100%; + height: 100%; + aspect-ratio: auto; + background-color: var(--surface-primary-color); +} + +.block-grid { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + aspect-ratio: 1; + place-items: stretch; +} + +.block-grid__canvas { + display: block; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + box-sizing: border-box; + border: 1px solid var(--surface-primary-color); + background: var(--surface-primary-color); + cursor: default; +} + +.block-grid__tooltip { + position: fixed; + z-index: 20; + max-width: min(360px, calc(100vw - 24px)); + padding: 10px 12px; + border: 1px solid rgba(23, 32, 28, .2); + border-radius: 6px; + color: #17201c; + background: rgba(255, 255, 255, .96); + box-shadow: 0 10px 30px rgba(23, 32, 28, .16); +} + +.block-grid__tooltip[hidden] { + display: none; +} + +.block-grid__tooltip dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px 10px; + margin: 0; + font-size: 12px; +} + +.block-grid__tooltip dt { + color: #67706a; +} + +.block-grid__tooltip dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.block-grid__tooltip a { + color: #1b6fb8; +} + +.pending-block-grid-loading { + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + background-color: var(--surface-secondary-color); + background-image: + linear-gradient(to right, var(--surface-primary-color) 1px, transparent 1px), + linear-gradient(to bottom, var(--surface-primary-color) 1px, transparent 1px); + background-size: 7px 7px; + place-items: center; +} + +.pending-block-grid-loading-wave { + display: grid; + grid-template-columns: repeat(5, 8px); + gap: 4px; +} + +.pending-block-grid-loading-wave span { + width: 8px; + height: 8px; + border-radius: 2px; + background-color: rgb(var(--accent-color-rgb)); + animation: pending-block-grid-wave 1.1s ease-in-out infinite; +} + +@keyframes pending-block-grid-wave { + 0%, + 55%, + 100% { + opacity: .15; + transform: scale(.65); + } + + 25% { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .pending-block-grid-loading-wave span { + opacity: .4; + transform: none; + animation: none; + } + + .pending-block-grid-loading-wave span:nth-child(13) { + opacity: 1; + } +} + +.pending-block-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin-top: 12px; + justify-content: space-around; +} + +.pending-block-legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.pending-block-color-reference { + width: 12px; + height: 12px; + border-radius: 4px; +} + +.pending-block-color-reference.success { + background-color: rgb(var(--success-color-rgb)); +} + +.pending-block-color-reference.warning { + background-color: rgb(var(--warning-color-rgb)); +} + +.pending-block-color-reference.danger { + background-color: rgb(var(--danger-color-rgb)); +} + +.pending-block-legend-label { + margin: 0; + font-size: 11px; +} + +.expanded-pending-block-stats { + display: flex; + flex: .55; + flex-direction: column; + gap: 12px; +} + +.expanded-pending-block-row { + display: flex; + flex: 1; + gap: 12px; +} + +.expanded-pending-block-row-large { + flex: 1.3; +} + +.expanded-pending-block-row > .info-card-container { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + height: 100%; + box-sizing: border-box; + padding: 12px; + border: .5px solid #333; + border-radius: 4px; + background-color: var(--surface-secondary-color); +} + +.low-fee .info-card-value { + color: #22C55E; +} + +.high-fee .info-card-value { + color: #EB4028; +} + +.total-fees-collected .info-card-value { + color: var(--accent-color); +} + +.pending-transactions .info-card-value { + color: #C5A422; +} + +.blocks-history-divider { + display: block; + width: 100%; + height: 4px; + margin-top: 20px; + overflow: visible; +} + +.blocks-history-divider line { + stroke: var(--foreground-muted); + stroke-width: 2; + stroke-dasharray: 8 8; + stroke-linecap: round; +} + +.blocks-section-title { + margin: 20px 0 0; + color: var(--foreground-muted); + font-family: "Rigid Square", sans-serif; + font-size: 14px; + font-weight: 600; +} + +.bar-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.expanded-pending-block-stats .info-card-container > div:last-child { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.bar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.bar-title { + font-weight: 400; + font-size: 10px; + color: var(--foreground-muted); + text-transform: uppercase; +} + +.bar-outline { + background-color: var(--surface-primary-color); + width: 100%; + height: 6px; + border-radius: 100px; + position: relative; +} + +.bar-fill { + height: 6px; + border-radius: 100px; + position: absolute; + left: 0px; +} + +.block-weight-weight-bar { + background-color: rgba(var(--accent-color-rgb), .5); +} + +.block-weight-size-bar { + background-color: #00C3FF; +} + +.block-weight-segwit-bar-fill { + background-color: var(--success-color); +} + +.block-weight-taproot-bar-fill { + background-color: rgba(var(--accent-color-rgb), .5); +} + +.bar-header-value { + margin-left: auto; + color: #FAFAFA; + font-size: 14px; + font-weight: 600; +} + +.bar-footer { + display: flex; + justify-content: space-between; + color: var(--foreground-muted); + font-size: 10px; + font-weight: 400; + text-transform: uppercase; +} + +@media only screen and (max-width: 820px) { + .pending-block-details-card-summary { + flex-direction: column; + } + + .pending-block-details { + margin-top: 16px; + margin-left: 0; + } + + .pending-block-details-card-header { + flex-wrap: wrap; + } + + .pending-block-details-button { + font-size: 14px; + } + + .pending-block-progress { + margin-top: 16px; + } + + .expanded-pending-block-details { + flex-direction: column; + height: auto; + } + + .expanded-pending-block-grid-container { + padding: 16px 0; + } + + .pending-block.block-grid { + height: auto; + aspect-ratio: 1; + } + + .expanded-pending-block-row { + flex-direction: column; + } .block-details-card-summary { flex-direction: column; From f4a0a26a9b2fe337e70f1e2f8d3657c57d17ac9c Mon Sep 17 00:00:00 2001 From: Randall Naar Date: Mon, 10 Aug 2026 17:36:45 -0400 Subject: [PATCH 2/2] Add new block details pane. --- client/src/app.js | 29 ++- client/src/components/block-details-card.js | 190 +++++++++++++++- client/src/components/icons.js | 5 + client/src/components/mempool-congestion.js | 35 +++ .../src/components/transaction-block-grid.js | 2 +- client/src/lib/block-template.js | 8 + client/src/lib/mempool.js | 11 + client/src/views/block.js | 49 +--- client/src/views/blocks.js | 7 +- client/src/views/overview.js | 35 +-- .../src/views/pending-block-details-card.js | 159 ++++++++----- client/src/views/tx.js | 1 + www/style.css | 212 +++++++++++++++--- 13 files changed, 559 insertions(+), 184 deletions(-) create mode 100644 client/src/components/mempool-congestion.js diff --git a/client/src/app.js b/client/src/app.js index 3276f48af..3f03aba11 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -72,6 +72,23 @@ const trackNewEntries = (items$, getId, getNewIds=defaultNewIds) => { .scan((current, mod) => mod(current), {}) } +const trackPendingBlockTemplateUpdate = (previous, template) => { + if (!template || !Array.isArray(template.transactions)) { + return { template: null, key: null, transactionCount: null, delta: null } + } + + const key = template.previousblockhash != null + ? template.previousblockhash + : template.height + , transactionCount = template.transactions.length + 1 + , isSamePendingBlock = previous.key != null && key != null && previous.key == key + , delta = isSamePendingBlock && previous.transactionCount != null + ? transactionCount - previous.transactionCount + : null + + return { template, key, transactionCount, delta: delta || null } +} + export default function main({ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ }) { const @@ -257,9 +274,15 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , mempool$ = reply('mempool').startWith(null) , mempoolRecent$ = reply('recent') , newTxEntries$ = trackNewEntries(mempoolRecent$, tx => tx.txid) - , blockTemplate$ = isBitcoinNetwork + , blockTemplateState$ = (isBitcoinNetwork ? reply('block-template').startWith(null) - : O.of(null) + : O.of(null)) + .scan(trackPendingBlockTemplateUpdate, { + template: null, + key: null, + transactionCount: null, + delta: null, + }) // dashboard , dashboardPegAsset$ = !showPegData @@ -382,7 +405,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // App state , state$ = combine({ t$, error$, tipHeight$, spends$ , goBlocks$, blocks$, nextBlocks$, prevBlocks$, dashboardState$ - , pendingBlockDetailsOpen$, blockTemplate$ + , pendingBlockDetailsOpen$, blockTemplateState$ , dashboardEpochStartBlock$, dashboardPreviousDifficultyBlock$ , newBlockEntries$, newTxEntries$ , goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$ diff --git a/client/src/components/block-details-card.js b/client/src/components/block-details-card.js index e7e08a4bd..054b51a0a 100644 --- a/client/src/components/block-details-card.js +++ b/client/src/components/block-details-card.js @@ -1,22 +1,171 @@ import { BlockGrid } from "./block-grid"; +import { InfoCard } from "./info-card"; import { InfoStat } from "./info-stat"; +import { MinusIcon, PlusIcon } from "./icons"; import { StatusBadge } from "./status-badge"; import { ElapsedTime } from "./elapsed-time"; import { Tooltip } from "./tooltip"; +import { maxBlockWeight } from "../const"; import { + formatHex, formatTime, formatVMB, getBlockPercentageUsed, } from "../views/util"; +// Require behind env conditional so it gets removed by `envify` on non-elements builds +const BlockSignatures = + process.env.IS_ELEMENTS && + require("./block-signatures").default; + const staticRoot = process.env.STATIC_ROOT || ""; const formatInteger = (value) => Number.isFinite(value) ? value.toLocaleString() : "N/A"; +const formatScaledValue = (value, divisor, suffix) => { + if (!Number.isFinite(value)) return "N/A"; + + return `${(value / divisor).toFixed(2).replace(/\.00$/, "")} ${suffix}`; +}; + +const formatVirtualSize = (weight) => { + if (!Number.isFinite(weight)) return "N/A"; + + const virtualSize = Math.ceil(weight / 4); + if (virtualSize < 1_000) return `${virtualSize.toLocaleString()} vB`; + if (virtualSize < 1_000_000) { + return formatScaledValue(virtualSize, 1_000, "vKB"); + } + + return formatScaledValue(virtualSize, 1_000_000, "vMB"); +}; + +const clampPercentage = (value) => + Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0; + +const detailTooltip = (text) => ({ + iconSrc: `${staticRoot}img/icons/tooltip.svg`, + text, +}); + +const detailBar = ({ title, headerValue, fillPercentage, fillClass }) => ( +
+
+
{title}
+
{headerValue}
+
+
+
+
+
+); + +const ExpandedBlockDetails = ({ block }) => { + const weightPercentage = getBlockPercentageUsed(block.weight); + const sizePercentage = (block.size / maxBlockWeight) * 100; + + return ( +
+ } + footer={`Block #${block.height.toLocaleString()}`} + /> + + + {detailBar({ + title: "Weight", + headerValue: `${formatScaledValue( + block.weight, + 1_000_000, + "MWU", + )} / ${formatScaledValue( + maxBlockWeight, + 1_000_000, + "MWU", + )}`, + fillPercentage: weightPercentage, + fillClass: "block-weight-weight-bar", + })} + {detailBar({ + title: "Size", + headerValue: `${formatScaledValue( + block.size, + 1_000_000, + "MB", + )} / ${formatScaledValue( + maxBlockWeight, + 1_000_000, + "MB", + )}`, + fillPercentage: sizePercentage, + fillClass: "block-weight-size-bar", + })} +
+ } + /> + + + {process.env.IS_ELEMENTS ? ( + } + /> + ) : ( + + )} + +
+ ); +}; + const BlockDetailsCard = ({ className, block, + detailsOpen = false, statusText, statusVariant = "success", t, @@ -26,7 +175,13 @@ const BlockDetailsCard = ({ : 0; return ( -
+
@@ -56,25 +211,38 @@ const BlockDetailsCard = ({ {statusText ? ( {statusText} ) : null} + +
+ ) : ( + "N/A" + ) + } + /> + -
@@ -105,6 +273,8 @@ const BlockDetailsCard = ({
+ + {detailsOpen && block ? : null} ); }; diff --git a/client/src/components/icons.js b/client/src/components/icons.js index 8220c9971..22883416f 100644 --- a/client/src/components/icons.js +++ b/client/src/components/icons.js @@ -48,6 +48,11 @@ export const MinusIcon = ({ className } = {}) => +export const LightningBoltIcon = ({ className } = {}) => + + export const ClockIcon = ({ className } = {}) =>
+
+ {congestion.level || fallback} +
+
+
+
+
+

LOW

+

HIGH

+
+
+ ); +}; diff --git a/client/src/components/transaction-block-grid.js b/client/src/components/transaction-block-grid.js index e8cc15019..fc9144951 100644 --- a/client/src/components/transaction-block-grid.js +++ b/client/src/components/transaction-block-grid.js @@ -716,7 +716,7 @@ export function renderBlockGrid( ]; const txidTerm = document.createElement("dt"); const txidDefinition = document.createElement("dd"); - txidTerm.textContent = "txid"; + txidTerm.textContent = "transaction"; if (normalizedBaseUrl) { const link = document.createElement("a"); diff --git a/client/src/lib/block-template.js b/client/src/lib/block-template.js index 62385cf28..bde97a655 100644 --- a/client/src/lib/block-template.js +++ b/client/src/lib/block-template.js @@ -90,6 +90,9 @@ export const summarizeBlockTemplate = (template, feeEst) => { const segwitCount = hasCompleteTransactionData ? transactions.filter(isSegwitTransaction).length : null; + const legacyCount = hasCompleteTransactionData + ? transactions.length - segwitCount + : null; return { height: Number.isFinite(template.height) ? template.height : null, @@ -114,5 +117,10 @@ export const summarizeBlockTemplate = (template, feeEst) => { ? percentage(segwitCount, transactions.length) : 0 : null, + legacyPercentage: hasCompleteTransactionData + ? transactions.length + ? percentage(legacyCount, transactions.length) + : 0 + : null, }; }; diff --git a/client/src/lib/mempool.js b/client/src/lib/mempool.js index cb33b6832..76a86cc15 100644 --- a/client/src/lib/mempool.js +++ b/client/src/lib/mempool.js @@ -19,3 +19,14 @@ const congestionClassByLevel = { export const getMempoolCongestionClass = (level) => congestionClassByLevel[level] || ""; + +export const getMempoolCongestion = (mempool) => { + const usage = getMempoolUsage(mempool); + const level = mempool ? getMempoolCongestionLevel(usage) : ""; + + return { + className: getMempoolCongestionClass(level), + level, + percentage: usage * 100, + }; +}; diff --git a/client/src/views/block.js b/client/src/views/block.js index 84de3bd68..b8d63b3bb 100644 --- a/client/src/views/block.js +++ b/client/src/views/block.js @@ -1,6 +1,5 @@ import layout from "./layout"; import { txBox } from "./tx"; -import { formatHex } from "./util"; import loader from "../components/loading"; import { BlockIcon, @@ -9,11 +8,6 @@ import { } from "../components/icons"; import BlockDetailsCard from "../components/block-details-card"; -// Require behind env conditional so it gets removed by `envify` on non-elements builds -const BlockSignatures = - process.env.IS_ELEMENTS && - require("../components/block-signatures").default; - const staticRoot = process.env.STATIC_ROOT || ""; const makeStatus = (b) => @@ -66,6 +60,7 @@ export default ({ -
-
-
-
-

{t`Version`}

-
-

{formatHex(b.version)}

-
-
- -
-

BLOCK HASH

-
-

{b.id}

-
-
-
- -
-
-

{t`Merkle root`}

-
-

{b.merkle_root}

-
-
- -
-

- {process.env.IS_ELEMENTS ? "BLOCK SIGNATURES" : t`Nonce`} -

-
- {process.env.IS_ELEMENTS ? ( - - ) : ( -

{formatHex(b.nonce)}

- )} -
-
-
-
-
-
{blockTxs ? blockTxs.map((tx, index) => diff --git a/client/src/views/blocks.js b/client/src/views/blocks.js index 42f62872f..35390c10d 100644 --- a/client/src/views/blocks.js +++ b/client/src/views/blocks.js @@ -31,10 +31,15 @@ export const blks = (blocks, viewMore, { t, ...S }) => ( ) : ( "" diff --git a/client/src/views/overview.js b/client/src/views/overview.js index 3da803aa2..eb4cd3ce2 100644 --- a/client/src/views/overview.js +++ b/client/src/views/overview.js @@ -4,12 +4,8 @@ import { } from "../const"; import { ElapsedTime } from "../components/elapsed-time"; import { InfoCard } from "../components/info-card"; +import { MempoolCongestion } from "../components/mempool-congestion"; import { ReferenceLineChart } from "../components/reference-line-chart"; -import { - getMempoolCongestionClass, - getMempoolCongestionLevel, - getMempoolUsage, -} from "../lib/mempool"; const staticRoot = process.env.STATIC_ROOT || ""; @@ -59,15 +55,6 @@ export const overview = ({ currentBitcoinPrice, feeEst, ); - const mempoolUsage = getMempoolUsage(mempool); - const mempoolUsagePercent = Math.round(mempoolUsage * 10000) / 100; - const mempoolCongestionLevel = mempool - ? getMempoolCongestionLevel(mempoolUsage) - : ""; - const mempoolCongestionClass = getMempoolCongestionClass( - mempoolCongestionLevel, - ); - return (

Overview

@@ -106,25 +93,7 @@ export const overview = ({ iconSrc: `${staticRoot}img/icons/tooltip.svg`, text: "How busy mempool activity is. More congestion means higher fees for quick confirmation.", }} - body={ -
-
- {mempoolCongestionLevel} -
-
-
-
-
-

LOW

-

HIGH

-
-
- } + body={} /> value + Math.max(1, Math.abs(value)) * Number.EPSILON * 4; @@ -285,6 +286,12 @@ const EstimatedBlockTime = ({ timestamp }) => ( const formatPercentage = (value, fallback = "N/A") => Number.isFinite(value) ? `${value.toFixed(2)}%` : fallback; +const formatTrimmedDecimal = (value) => + value.toFixed(2).replace(/\.?0+$/, ""); + +const formatPanelPercentage = (value, fallback = "N/A") => + Number.isFinite(value) ? `${formatTrimmedDecimal(value)}%` : fallback; + const formatFeeRate = (value, fallback = "N/A") => Number.isFinite(value) ? `${value.toFixed(2)} sat/vB` : fallback; @@ -293,7 +300,12 @@ const formatFeeBoundary = (value, fallback = "N/A") => const formatWeight = (value, fallback = "N/A") => Number.isFinite(value) - ? `${(value / 1_000_000).toFixed(2)} MWU` + ? `${formatTrimmedDecimal(value / 1_000_000)} MWU` + : fallback; + +const formatMegabytes = (value, fallback = "N/A") => + Number.isFinite(value) + ? `${formatTrimmedDecimal(value / 1_000_000)} MB` : fallback; const formatCount = (value, fallback = "N/A") => @@ -329,6 +341,16 @@ const formatFeeCost = (sats, bitcoinPrice, fallback = "N/A") => { return `${formatBitcoin(sats, fallback)} / ${formatUsd(usdValue, fallback)}`; }; +const panelTooltip = (text) => ({ + iconSrc: `${staticRoot}img/icons/tooltip.svg`, + text, +}); + +const formatTransactionDelta = (delta) => + delta > 0 + ? `+ ${formatCount(delta)}` + : `− ${formatCount(Math.abs(delta))}`; + const blockStat = (title, value) => (

{title}

@@ -363,10 +385,11 @@ const bar = ({
); -const detailPanel = (className, title, value, footer) => ( +const detailPanel = (className, title, value, footer, tooltipText) => ( @@ -379,6 +402,7 @@ const feeBucketPanel = ( bitcoinPrice, valueFallback, costFallback, + tooltipText, ) => detailPanel( className, @@ -389,6 +413,7 @@ const feeBucketPanel = ( bucket ? formatFeeCost(bucket.averageFee, bitcoinPrice, costFallback) : valueFallback, + tooltipText, ); const PendingBlockDetailsCard = ({ @@ -398,6 +423,7 @@ const PendingBlockDetailsCard = ({ detailsOpen, feeEst, mempool, + transactionDelta, }) => { const templateFallback = blockTemplate ? "N/A" : "-"; const feeEstimateFallback = feeEst ? "N/A" : "-"; @@ -420,15 +446,6 @@ const PendingBlockDetailsCard = ({ : null; const bitcoinPrice = getLatestBitcoinPrice(bitcoinMarketChart); const weightPercentage = metrics && metrics.weightPercentage; - const mempoolUsage = getMempoolUsage(mempool); - const mempoolUsagePercentage = mempool ? mempoolUsage * 100 : null; - const mempoolCongestionLevel = mempool - ? getMempoolCongestionLevel(mempoolUsage) - : ""; - const mempoolCongestionClass = getMempoolCongestionClass( - mempoolCongestionLevel, - ); - return (
@@ -512,12 +529,6 @@ const PendingBlockDetailsCard = ({ }} >
- -

- {metrics - ? `${formatWeight(metrics.totalWeight)} / ${formatWeight(metrics.weightLimit)}` - : templateFallback} -

@@ -591,18 +602,54 @@ const PendingBlockDetailsCard = ({ "-" ), block ? `Block #${block.height.toLocaleString()}` : "-", + "Elapsed time since the last block confirmed. Bitcoin targets one about every 10 minutes.", )} - {detailPanel( - "block-transactions", - "Transactions", - formatCount( - metrics && metrics.transactionCount, - templateFallback, - ), - metrics - ? `${formatCount(metrics.templateTransactionCount)} SELECTED + COINBASE` - : templateFallback, - )} + + + Live + + } + value={ + + + {formatCount( + metrics && metrics.transactionCount, + templateFallback, + )} + + {Number.isFinite(transactionDelta) && + transactionDelta !== 0 ? ( + 0 ? "added" : "removed" + } since the last update`} + > + {formatTransactionDelta(transactionDelta)} + + + ) : null} + + } + footer={ + metrics + ? `${formatCount(metrics.templateTransactionCount)} SELECTED + COINBASE` + : templateFallback + } + />
{feeBucketPanel( @@ -612,14 +659,16 @@ const PendingBlockDetailsCard = ({ bitcoinPrice, feeBucketFallback, feeCostFallback, + "Average fee rate and transaction fee in the low-fee portion of this template.", )} {feeBucketPanel( "avg-fee", - "Medium", + "Average", metrics && metrics.feeBuckets.medium, bitcoinPrice, feeBucketFallback, feeCostFallback, + "Average fee rate and transaction fee in the middle-fee portion of this template.", )} {feeBucketPanel( "high-fee", @@ -628,6 +677,7 @@ const PendingBlockDetailsCard = ({ bitcoinPrice, feeBucketFallback, feeCostFallback, + "Average fee rate and transaction fee in the high-fee portion of this template.", )}
@@ -644,27 +694,33 @@ const PendingBlockDetailsCard = ({ (metrics.totalFees / satoshisPerBitcoin) * bitcoinPrice, ) : totalFeesUsdFallback, + "Total transaction fees a miner would collect from the current template, shown in bitcoin and US dollars.", )}
{bar({ title: "SegWit", headerValue: metrics - ? `${formatPercentage(metrics.segwitPercentage)} · ${formatCount(metrics.segwitCount)}/${formatCount(metrics.templateTransactionCount)} TX` + ? formatPanelPercentage(metrics.segwitPercentage) : templateFallback, fillPercentage: metrics && metrics.segwitPercentage, barFillClass: "block-weight-segwit-bar-fill", })} {bar({ - title: "Taproot", - headerValue: "N/A", - fillPercentage: null, - barFillClass: "block-weight-taproot-bar-fill", + title: "Legacy", + headerValue: metrics + ? formatPanelPercentage(metrics.legacyPercentage) + : templateFallback, + fillPercentage: metrics && metrics.legacyPercentage, + barFillClass: "block-weight-legacy-bar-fill", })}
} @@ -673,6 +729,9 @@ const PendingBlockDetailsCard = ({ {bar({ @@ -689,7 +748,7 @@ const PendingBlockDetailsCard = ({ metrics && Number.isFinite(metrics.totalSize) && Number.isFinite(metrics.sizeLimit) - ? `${formatVMB(metrics.totalSize, "MB")} / ${formatVMB(metrics.sizeLimit, "MB")}` + ? `${formatMegabytes(metrics.totalSize)} / ${formatMegabytes(metrics.sizeLimit)}` : templateFallback, fillPercentage: metrics && metrics.sizePercentage, barFillClass: "block-weight-size-bar", @@ -704,22 +763,20 @@ const PendingBlockDetailsCard = ({ "Pending Transactions", formatCount(mempool && mempool.count, mempoolFallback), mempool ? "IN MEMPOOL" : mempoolFallback, + "Transactions currently waiting in the node's mempool.", )} - {bar({ - title: "Usage", - headerValue: mempool - ? `${mempoolCongestionLevel} · ${formatPercentage(mempoolUsagePercentage)}` - : mempoolFallback, - fillPercentage: mempoolUsagePercentage, - barFillClass: `mempool-congestion-fill ${mempoolCongestionClass}`, - })} - + } /> diff --git a/client/src/views/tx.js b/client/src/views/tx.js index b726b339e..5b25f1b86 100644 --- a/client/src/views/tx.js +++ b/client/src/views/tx.js @@ -93,6 +93,7 @@ export default ({ className="transaction-block-details" block={block} t={t} + detailsOpen={block && S.openBlock === block.id} statusText={t`Confirmed`} /> diff --git a/www/style.css b/www/style.css index 12c9ec8e0..2896bc9ea 100644 --- a/www/style.css +++ b/www/style.css @@ -4069,12 +4069,13 @@ a.back-link img{ font-size: 20px; font-family: 'Rigid Square'; font-weight: 700; - margin-top: 5px; + margin-top: 12px; + line-height: 1; } .info-card-footer { margin: 0px; - margin-top: 4px; + margin-top: 6px; font-size: 10px; color: var(--foreground-muted); font-weight: 400; @@ -4317,12 +4318,39 @@ a.back-link img{ .block-details-card-header { display: flex; + flex-wrap: wrap; gap: 12px; align-items: center; min-height: 20px; margin-top: 10px; } +.block-details-card-details-button { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 4px; + border: 0; + color: var(--foreground-link-color); + background: none; + font-family: "Rigid Square", sans-serif; + font-size: 16px; + font-weight: 700; + cursor: pointer; +} + +.block-details-card-details-button svg { + width: 14px; + height: 14px; +} + +.block-details-card-details-button:focus-visible { + border-radius: 2px; + outline: 2px solid var(--accent-color); + outline-offset: 3px; +} + .block-details-card-grid { /* These variables are pulled by the BlockGrid component */ --block-grid-empty-color: var(--surface-secondary-color); @@ -4363,7 +4391,6 @@ a.back-link img{ flex-direction: column; width: 100%; min-width: 0; - margin-left: 16px; padding: 10px 0px; } @@ -4384,6 +4411,11 @@ a.back-link img{ .block-details-card-summary { display: flex; + gap: 16px; +} + +.block-details-card-body { + min-width: 0; } .block-details-card-progress { @@ -4427,6 +4459,46 @@ a.back-link img{ background-repeat: no-repeat; } +.expanded-block-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 32px; +} + +.expanded-block-details > .info-card-container { + width: 100%; + height: auto; + box-sizing: border-box; + padding: 12px; + border-radius: 4px; + background-color: var(--surface-secondary-color); +} + +.expanded-block-details .info-card-value { + overflow-wrap: anywhere; +} + +.expanded-block-details .block-detail-panel-full { + grid-column: 1 / -1; +} + +.expanded-block-details > .block-detail-weight-panel { + min-height: 164px; +} + +.block-detail-bars { + display: flex; + flex-direction: column; + gap: 18px; + margin-top: 18px; +} + +.block-detail-signatures-panel .block-signatures { + align-items: flex-start; + margin-top: 8px; +} + .transaction-block-details { margin-top: 12px; } @@ -4843,15 +4915,18 @@ a.back-link img{ .expanded-pending-block-grid-container { display: flex; + align-items: center; flex: .45; flex-direction: column; - min-width: 0; - padding-right: 16px; + margin-right: 16px; + overflow-x: auto; } .pending-block-container { - flex: 1; - min-height: 0; + width: 100%; + --pending-block-grid-size: 580px; + height: var(--pending-block-grid-size); + box-sizing: border-box; padding: 8px; border: 2px solid rgba(var(--accent-color-rgb), .2); border-radius: 4px; @@ -4888,13 +4963,14 @@ a.back-link img{ .block-grid__tooltip { position: fixed; z-index: 20; - max-width: min(360px, calc(100vw - 24px)); - padding: 10px 12px; - border: 1px solid rgba(23, 32, 28, .2); + width: min(320px, calc(100vw - 24px)); + box-sizing: border-box; + padding: 14px 16px; + border: 0; border-radius: 6px; - color: #17201c; - background: rgba(255, 255, 255, .96); - box-shadow: 0 10px 30px rgba(23, 32, 28, .16); + color: #FAFAFA; + background-color: var(--surface-secondary-color); + box-shadow: 0 10px 30px rgba(0, 0, 0, .3); } .block-grid__tooltip[hidden] { @@ -4903,25 +4979,29 @@ a.back-link img{ .block-grid__tooltip dl { display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 5px 10px; + grid-template-columns: max-content minmax(0, 1fr); + gap: 10px 20px; margin: 0; - font-size: 12px; + font-family: "Inter", sans-serif; + font-size: 14px; + line-height: 1.2; } .block-grid__tooltip dt { - color: #67706a; + color: var(--foreground-muted); + text-transform: uppercase; } .block-grid__tooltip dd { min-width: 0; margin: 0; - overflow-wrap: anywhere; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #FAFAFA; + text-align: right; + white-space: nowrap; } .block-grid__tooltip a { - color: #1b6fb8; + color: var(--foreground-link-color); } .pending-block-grid-loading { @@ -4984,6 +5064,7 @@ a.back-link img{ gap: 8px 16px; margin-top: 12px; justify-content: space-around; + width: 100%; } .pending-block-legend-item { @@ -5037,10 +5118,9 @@ a.back-link img{ flex-direction: column; width: 100%; min-width: 0; - height: 100%; + height: auto; box-sizing: border-box; padding: 12px; - border: .5px solid #333; border-radius: 4px; background-color: var(--surface-secondary-color); } @@ -5061,6 +5141,47 @@ a.back-link img{ color: #C5A422; } +.pending-block-live-badge { + gap: 4px; +} + +.pending-block-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--success-color); + box-shadow: 0 0 0 4px rgba(var(--success-color-rgb), .15); +} + +.block-transactions .info-card-value { + width: 100%; +} + +.pending-block-transaction-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pending-block-transaction-delta { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--foreground-muted); + font-family: "Inter", sans-serif; + font-size: 11px; + font-weight: 400; +} + +.pending-block-transaction-delta svg { + flex: 0 0 auto; +} + +.pending-block-mempool-congestion .mempool-congestion-bar { + background-color: var(--surface-primary-color); +} + .blocks-history-divider { display: block; width: 100%; @@ -5093,7 +5214,6 @@ a.back-link img{ .expanded-pending-block-stats .info-card-container > div:last-child { display: flex; flex-direction: column; - gap: 12px; margin-top: 12px; } @@ -5138,7 +5258,7 @@ a.back-link img{ background-color: var(--success-color); } -.block-weight-taproot-bar-fill { +.block-weight-legacy-bar-fill { background-color: rgba(var(--accent-color-rgb), .5); } @@ -5180,39 +5300,57 @@ a.back-link img{ margin-top: 16px; } - .expanded-pending-block-details { + .expanded-pending-block-row { flex-direction: column; - height: auto; } +} - .expanded-pending-block-grid-container { - padding: 16px 0; - } +@media only screen and (max-width: 1328px) { - .pending-block.block-grid { + .pending-block-container { + width: 100%; height: auto; - aspect-ratio: 1; + aspect-ratio: 1 / 1; + box-sizing: border-box; + max-width: 500px; } - .expanded-pending-block-row { + .expanded-pending-block-details { flex-direction: column; + align-items: center; + height: auto; + } + + .expanded-pending-block-grid-container { + margin: 16px 0; + width: 100%; + } + + .expanded-pending-block-stats { + flex: none; + width: 100%; } .block-details-card-summary { - flex-direction: column; + flex-wrap: wrap; } .block-details-card-content { - margin-top: 16px; - margin-left: 0; + flex: 1 1 480px; + width: auto; } .block-details-card-progress { margin-top: 16px; } -} -@media only screen and (max-width: 1328px) { + .expanded-block-details { + grid-template-columns: repeat( + auto-fit, + minmax(min(100%, 320px), 1fr) + ); + } + .explorer-container { box-sizing: border-box; }