From eee1a10d2a4452fe150933a0fa8dba39fcc560af Mon Sep 17 00:00:00 2001 From: nym21 Date: Sun, 5 Jul 2026 14:08:57 +0200 Subject: [PATCH] website_next: part 2 --- website_next/btc/index.js | 4 +- website_next/btc/style.css | 4 +- website_next/explore/block/difficulty.js | 79 +++ website_next/explore/block/fee-chart.js | 10 +- website_next/explore/block/header.js | 91 ++++ website_next/explore/block/index.js | 546 +-------------------- website_next/explore/block/miner.js | 57 +++ website_next/explore/block/pane.js | 19 + website_next/explore/block/rewards.js | 173 +++++++ website_next/explore/block/style.css | 111 +---- website_next/explore/block/transactions.js | 72 +++ website_next/explore/chain/block-cube.js | 178 +++++++ website_next/explore/chain/cube/index.js | 2 +- website_next/explore/chain/cube/style.css | 16 +- website_next/explore/chain/edge.js | 22 + website_next/explore/chain/events.js | 11 + website_next/explore/chain/format.js | 55 +++ website_next/explore/chain/index.js | 443 +++-------------- website_next/explore/chain/scroll.js | 105 ++++ website_next/explore/chain/style.css | 28 +- website_next/explore/chain/transition.js | 32 ++ website_next/utils/fee-rate.js | 9 + website_next/wallets/amount/index.js | 3 +- 23 files changed, 1024 insertions(+), 1046 deletions(-) create mode 100644 website_next/explore/block/difficulty.js create mode 100644 website_next/explore/block/header.js create mode 100644 website_next/explore/block/miner.js create mode 100644 website_next/explore/block/pane.js create mode 100644 website_next/explore/block/rewards.js create mode 100644 website_next/explore/block/transactions.js create mode 100644 website_next/explore/chain/block-cube.js create mode 100644 website_next/explore/chain/edge.js create mode 100644 website_next/explore/chain/events.js create mode 100644 website_next/explore/chain/format.js create mode 100644 website_next/explore/chain/scroll.js create mode 100644 website_next/explore/chain/transition.js create mode 100644 website_next/utils/fee-rate.js diff --git a/website_next/btc/index.js b/website_next/btc/index.js index ae447f472..849d8e9e3 100644 --- a/website_next/btc/index.js +++ b/website_next/btc/index.js @@ -128,10 +128,11 @@ export function getBtcParts(sats, options = {}) { * @param {BtcAmountOptions} [options] */ export function renderBtcAmount(element, sats, options = {}) { + element.dataset.btcAmount = ""; element.replaceChildren(...getBtcParts(sats, options).map((part) => { const span = document.createElement("span"); - if (part.muted) span.classList.add("muted"); + if (part.muted) span.dataset.btcMuted = ""; span.append(part.text); return span; @@ -147,7 +148,6 @@ export function renderBtcAmount(element, sats, options = {}) { export function createBtcAmount(tag, sats, options = {}) { const element = document.createElement(tag); - element.classList.add("amount"); renderBtcAmount(element, sats, options); return element; diff --git a/website_next/btc/style.css b/website_next/btc/style.css index 321bf2d77..71c78fab4 100644 --- a/website_next/btc/style.css +++ b/website_next/btc/style.css @@ -1,5 +1,5 @@ -.amount { - .muted { +[data-btc-amount] { + [data-btc-muted] { color: color-mix(in oklch, currentColor 45%, transparent); } } diff --git a/website_next/explore/block/difficulty.js b/website_next/explore/block/difficulty.js new file mode 100644 index 000000000..e115ba9eb --- /dev/null +++ b/website_next/explore/block/difficulty.js @@ -0,0 +1,79 @@ +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ + +const DIFFICULTY_EPOCH_BLOCKS = 2_016; +const HALVING_EPOCH_BLOCKS = 210_000; + +/** + * @param {string} label + * @param {string} value + */ +function createMetricStat(label, value) { + const stat = document.createElement("div"); + const name = document.createElement("span"); + const amount = document.createElement("strong"); + + stat.dataset.metricStat = ""; + name.textContent = label; + amount.textContent = value; + stat.append(name, amount); + + return stat; +} + +/** + * @param {string} label + * @param {number} height + * @param {number} length + * @param {string} color + */ +function createEpochProgress(label, height, length, color) { + const progress = (height % length) + 1; + const row = document.createElement("div"); + const head = document.createElement("div"); + const name = document.createElement("span"); + const value = document.createElement("strong"); + const bar = document.createElement("div"); + const done = document.createElement("span"); + const remaining = document.createElement("span"); + + row.dataset.epoch = ""; + head.dataset.epochHead = ""; + bar.dataset.epochBar = ""; + done.dataset.epochSegment = "done"; + remaining.dataset.epochSegment = "remaining"; + row.style.setProperty("--epoch-color", color); + done.style.setProperty("--share", `${(progress / length) * 100}%`); + remaining.style.setProperty("--share", `${((length - progress) / length) * 100}%`); + + name.textContent = label; + value.textContent = `${((progress / length) * 100).toFixed(1)}%`; + head.append(name, value); + bar.append(done, remaining); + row.append(head, bar); + + return row; +} + +/** @param {Block} block */ +export function createDifficultyPane(block) { + const pane = document.createElement("div"); + + pane.dataset.metricList = ""; + pane.append( + createMetricStat("Difficulty", block.difficulty.toLocaleString()), + createEpochProgress( + "Difficulty epoch", + block.height, + DIFFICULTY_EPOCH_BLOCKS, + "var(--orange)", + ), + createEpochProgress( + "Halving epoch", + block.height, + HALVING_EPOCH_BLOCKS, + "var(--red)", + ), + ); + + return pane; +} diff --git a/website_next/explore/block/fee-chart.js b/website_next/explore/block/fee-chart.js index 1d73f55ca..0d748f82a 100644 --- a/website_next/explore/block/fee-chart.js +++ b/website_next/explore/block/fee-chart.js @@ -2,6 +2,7 @@ import { createXyChart } from "../../chart/xy/index.js"; import { createChartPoint, createChartPoints } from "../../chart/points.js"; import { createLinearXScale } from "../../chart/x.js"; import { createValueYScale } from "../../chart/y.js"; +import { formatFeeRate } from "../../utils/fee-rate.js"; export const FEE_PERCENTILE_LABELS = /** @type {const} */ ([ "min", @@ -151,9 +152,8 @@ function plotSeries(percentileSamples, entries, frame) { /** * @param {number[]} values * @param {number} averageRate - * @param {(value: number) => string} formatRate */ -export function createFeeChart(values, averageRate, formatRate) { +export function createFeeChart(values, averageRate) { const percentileSamples = createPercentileSamples(values); const entries = createEntries(percentileSamples, averageRate); const figure = createXyChart({ @@ -161,11 +161,11 @@ export function createFeeChart(values, averageRate, formatRate) { unit: { id: "sat/vB", name: "satoshis per virtual byte", - format: formatRate, + format: formatFeeRate, }, - ariaLabel: `Fee rate percentiles from ${formatRate( + ariaLabel: `Fee rate percentiles from ${formatFeeRate( values[0], - )} to ${formatRate(values[values.length - 1])} sat/vB`, + )} to ${formatFeeRate(values[values.length - 1])} sat/vB`, fallbackHeight: VIEWBOX_HEIGHT, series: createSeries(entries), plot: (frame) => plotSeries(percentileSamples, entries, frame), diff --git a/website_next/explore/block/header.js b/website_next/explore/block/header.js new file mode 100644 index 000000000..94038e57e --- /dev/null +++ b/website_next/explore/block/header.js @@ -0,0 +1,91 @@ +import { createUsdAmount, renderUsdAmount } from "../../usd/index.js"; + +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ + +/** @param {number} unixSeconds */ +function formatDateTime(unixSeconds) { + return new Date(unixSeconds * 1_000).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }); +} + +/** @param {number} height */ +function createHeightElement(height) { + const element = document.createElement("span"); + const prefix = document.createElement("span"); + const value = document.createElement("span"); + + prefix.dataset.dim = ""; + prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; + value.textContent = String(height); + element.append(prefix, value); + + return element; +} + +/** @param {string} hash */ +function createHashElement(hash) { + const element = document.createElement("span"); + const prefix = document.createElement("span"); + const value = document.createElement("span"); + const firstNonZero = hash.search(/[^0]/); + const visibleStart = firstNonZero === -1 ? hash.length : firstNonZero; + + element.dataset.blockHash = ""; + prefix.dataset.dim = ""; + prefix.textContent = hash.slice(0, visibleStart); + value.textContent = hash.slice(visibleStart); + element.append(prefix, value); + + return element; +} + +/** @param {number} height */ +function createTitle(height) { + const label = document.createElement("span"); + const value = document.createElement("span"); + + label.dataset.titleLabel = ""; + value.dataset.titleHeight = ""; + label.textContent = "Block"; + value.append(createHeightElement(height)); + + return [label, value]; +} + +export function createBlockHeader() { + const element = document.createElement("header"); + const titleRow = document.createElement("div"); + const title = document.createElement("h1"); + const date = document.createElement("time"); + const meta = document.createElement("div"); + const hash = document.createElement("p"); + const price = createUsdAmount("output", 0, { + tone: "positive", + }); + + titleRow.dataset.blockTitle = ""; + date.dataset.blockDate = ""; + meta.dataset.blockMeta = ""; + hash.dataset.blockHashLine = ""; + titleRow.append(title, date); + meta.append(hash, price); + element.append(titleRow, meta); + + /** @param {Block} block */ + function update(block) { + title.replaceChildren(...createTitle(block.height)); + date.dateTime = new Date(block.timestamp * 1_000).toISOString(); + date.textContent = formatDateTime(block.timestamp); + hash.replaceChildren(createHashElement(block.id)); + renderUsdAmount(price, block.extras.price, { + tone: "positive", + }); + } + + return /** @type {const} */ ({ + element, + update, + }); +} diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js index 920fd1e47..76f2f1cc2 100644 --- a/website_next/explore/block/index.js +++ b/website_next/explore/block/index.js @@ -1,551 +1,39 @@ -import { createBtcAmount, SATS_PER_BTC } from "../../btc/index.js"; -import { - appendLegendListItem, - createLegendItem, - createLegendList, -} from "../../legend/index.js"; -import { createPoolLogo } from "../../pools/index.js"; -import { createUsdAmount, renderUsdAmount } from "../../usd/index.js"; -import { brk } from "../../utils/client.js"; +import { createBlockHeader } from "./header.js"; +import { createMinerPane } from "./miner.js"; +import { createDifficultyPane } from "./difficulty.js"; +import { createRewardsPane } from "./rewards.js"; +import { createTransactionPane } from "./transactions.js"; import { createFeeChart } from "./fee-chart.js"; - -/** @typedef {Awaited>[number]} Block */ - -const MAX_BLOCK_WEIGHT = 4_000_000; -const DIFFICULTY_EPOCH_BLOCKS = 2_016; -const HALVING_EPOCH_BLOCKS = 210_000; - -/** @param {number} bytes */ -function formatBytes(bytes) { - return bytes >= 1_000_000 - ? `${(bytes / 1_000_000).toFixed(2)} MB` - : `${bytes.toLocaleString()} B`; -} - -/** @param {number} rate */ -function formatFeeRate(rate) { - if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`; - if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`; - if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`; - if (rate >= 100) return Math.round(rate).toLocaleString(); - if (rate >= 10) return rate.toFixed(1); - return rate.toFixed(2); -} - -/** @param {number} unixSeconds */ -function formatDateTime(unixSeconds) { - return new Date(unixSeconds * 1_000).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "medium", - }); -} - -/** @param {number} height */ -function createHeightElement(height) { - const element = document.createElement("span"); - const prefix = document.createElement("span"); - const value = document.createElement("span"); - - prefix.classList.add("dim"); - prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; - value.textContent = String(height); - element.append(prefix, value); - - return element; -} - -/** @param {string} hash */ -function createHashElement(hash) { - const element = document.createElement("span"); - const prefix = document.createElement("span"); - const value = document.createElement("span"); - const firstNonZero = hash.search(/[^0]/); - const visibleStart = firstNonZero === -1 ? hash.length : firstNonZero; - - element.dataset.blockHash = ""; - prefix.classList.add("dim"); - prefix.textContent = hash.slice(0, visibleStart); - value.textContent = hash.slice(visibleStart); - element.append(prefix, value); - - return element; -} - -/** @param {number} height */ -function createTitle(height) { - const label = document.createElement("span"); - const value = document.createElement("span"); - - label.classList.add("title-label"); - value.classList.add("title-height"); - label.textContent = "Block"; - value.append(createHeightElement(height)); - - return [label, value]; -} - -/** - * @param {string} term - * @param {string | Node | null | undefined} value - */ -function createRow(term, value) { - if (value == null || value === "") return null; - - const row = document.createElement("div"); - const dt = document.createElement("dt"); - const dd = document.createElement("dd"); - - dt.textContent = term; - dd.append(value); - row.append(dt, dd); - - return row; -} - -/** @param {string} title */ -function groupName(title) { - return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); -} - -/** - * @param {string} label - * @param {(string | Node)[]} values - */ -function createInlineRow(label, values) { - const row = document.createElement("div"); - const name = document.createElement("span"); - const data = document.createElement("strong"); - - row.dataset.inlineRow = ""; - name.textContent = label; - data.append(...values); - row.append(name, data); - - return row; -} - -/** - * @param {string} label - * @param {string | Node} value - * @param {string} [type] - */ -function createInlineBox(label, value, type = "inline") { - const box = document.createElement("div"); - - box.dataset.blockBox = type; - box.append(createInlineRow(label, [value])); - - return box; -} - -/** @param {Block} block */ -function formatBlockFill(block) { - return `${((block.weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`; -} - -/** - * @param {string} label - * @param {string} value - */ -function createMetricStat(label, value) { - const stat = document.createElement("div"); - const name = document.createElement("span"); - const amount = document.createElement("strong"); - - stat.dataset.metricStat = ""; - name.textContent = label; - amount.textContent = value; - stat.append(name, amount); - - return stat; -} - -/** @param {string} raw */ -function getCoinbaseMessage(raw) { - return (raw.match(/[\x20-\x7e]{2,}/g) ?? []) - .map((value) => value.trim()) - .filter((value) => /[A-Za-z0-9]/.test(value)) - .join(" · "); -} - -/** @param {string} raw */ -function createCoinbaseMessage(raw) { - const message = getCoinbaseMessage(raw); - - if (!message) return null; - - const element = document.createElement("p"); - - element.dataset.coinbaseMessage = ""; - element.textContent = message; - - return element; -} - -/** - * @param {string} label - * @param {number} height - * @param {number} length - * @param {string} color - */ -function createEpochProgress(label, height, length, color) { - const progress = (height % length) + 1; - const row = document.createElement("div"); - const head = document.createElement("div"); - const name = document.createElement("span"); - const value = document.createElement("strong"); - const bar = document.createElement("div"); - const done = document.createElement("span"); - const remaining = document.createElement("span"); - - row.dataset.epoch = ""; - head.dataset.epochHead = ""; - bar.dataset.epochBar = ""; - done.dataset.epochSegment = "done"; - remaining.dataset.epochSegment = "remaining"; - row.style.setProperty("--epoch-color", color); - done.style.setProperty("--share", `${(progress / length) * 100}%`); - remaining.style.setProperty("--share", `${((length - progress) / length) * 100}%`); - - name.textContent = label; - value.textContent = `${((progress / length) * 100).toFixed(1)}%`; - head.append(name, value); - bar.append(done, remaining); - row.append(head, bar); - - return row; -} - -/** @param {Block} block */ -function createMinerSummary(block) { - const { pool } = block.extras; - const pane = document.createElement("div"); - const head = document.createElement("div"); - const identity = document.createElement("div"); - const title = document.createElement("div"); - const name = document.createElement("strong"); - const blockNumber = document.createElement("span"); - const slug = document.createElement("span"); - const logo = createPoolLogo(pool); - const coinbaseMessage = createCoinbaseMessage(block.extras.coinbaseSignatureAscii); - - pane.dataset.minerPane = ""; - head.dataset.minerHead = ""; - identity.dataset.minerIdentity = ""; - title.dataset.minerTitle = ""; - slug.dataset.minerSlug = ""; - logo.dataset.minerLogo = ""; - - name.textContent = pool.name; - // TODO: remove fallback after the server includes pool.blockNumber everywhere. - blockNumber.textContent = `#${(pool.blockNumber || 0).toLocaleString()}`; - slug.textContent = pool.slug; - title.append(name, blockNumber); - identity.append(title, slug); - head.append(identity, logo); - pane.append(head, ...(coinbaseMessage ? [coinbaseMessage] : [])); - - return pane; -} - -/** @param {Block} block */ -function createDifficultySummary(block) { - const pane = document.createElement("div"); - - pane.dataset.metricList = ""; - pane.append( - createMetricStat("Difficulty", block.difficulty.toLocaleString()), - createEpochProgress( - "Difficulty epoch", - block.height, - DIFFICULTY_EPOCH_BLOCKS, - "var(--orange)", - ), - createEpochProgress( - "Halving epoch", - block.height, - HALVING_EPOCH_BLOCKS, - "var(--red)", - ), - ); - - return pane; -} - -/** - * @param {number} sats - * @param {number} total - */ -function formatShare(sats, total) { - return `${((sats / total) * 100).toFixed(2)}%`; -} - -/** - * @param {number} sats - * @param {number} price - */ -function getSatsUsd(sats, price) { - return (sats / SATS_PER_BTC) * price; -} - -/** - * @param {number} sats - * @param {number} price - */ -function createSatsUsdAmount(sats, price) { - return createUsdAmount("span", getSatsUsd(sats, price)); -} - -/** - * @param {number} sats - * @param {number} total - * @param {number} price - */ -function createRewardDetail(sats, total, price) { - const detail = document.createDocumentFragment(); - - detail.append(createSatsUsdAmount(sats, price), " · ", formatShare(sats, total)); - - return detail; -} - -const REWARD_COLORS = /** @type {const} */ ({ - subsidy: "var(--orange)", - fees: "var(--green)", -}); - -/** @typedef {keyof typeof REWARD_COLORS} RewardType */ - -/** - * @param {RewardType} type - * @param {number} sats - * @param {number} total - */ -function createRewardSegment(type, sats, total) { - const segment = document.createElement("span"); - - segment.dataset.rewardSegment = type; - segment.dataset.rewardKey = type; - segment.style.setProperty("--share", `${(sats / total) * 100}%`); - - return segment; -} - -/** - * @param {RewardType} type - * @param {string} label - * @param {number} sats - * @param {number} total - * @param {number} price - */ -function createRewardPart(type, label, sats, total, price) { - const { button: part, value } = createLegendItem({ - label, - color: REWARD_COLORS[type], - ariaLabel: `Highlight ${label}`, - detail: createRewardDetail(sats, total, price), - }); - const amount = createBtcAmount("span", sats); - - part.dataset.rewardPart = type; - part.dataset.rewardKey = type; - value.replaceChildren(amount); - - return part; -} - -/** - * @param {string} label - * @param {number} sats - * @param {number} price - */ -function createRewardTotal(label, sats, price) { - const total = document.createElement("div"); - const name = document.createElement("span"); - const amount = createBtcAmount("strong", sats); - const usd = createSatsUsdAmount(sats, price); - - total.dataset.rewardTotal = ""; - name.textContent = label; - total.append(name, amount, usd); - - return total; -} - -/** @param {EventTarget | null} target */ -function getRewardKey(target) { - if (!(target instanceof HTMLElement)) return null; - - return target.closest("[data-reward-key]")?.getAttribute("data-reward-key") ?? null; -} - -/** - * @param {HTMLElement} rewards - * @param {string | null} activeKey - */ -function setRewardPreview(rewards, activeKey) { - for (const element of rewards.querySelectorAll("[data-reward-key]")) { - if (!(element instanceof HTMLElement)) continue; - - if (element.dataset.rewardKey === activeKey) { - element.dataset.preview = ""; - delete element.dataset.muted; - } else if (activeKey) { - element.dataset.muted = ""; - delete element.dataset.preview; - } else { - delete element.dataset.muted; - delete element.dataset.preview; - } - } -} - -/** @param {Block["extras"]} extras */ -function createRewardSummary(extras) { - const subsidy = extras.reward - extras.totalFees; - const rewards = document.createElement("div"); - const bar = document.createElement("div"); - const split = createLegendList({ fill: true }); - - rewards.dataset.statBox = "rewards"; - appendLegendListItem( - split, - createRewardPart("subsidy", "Subsidy", subsidy, extras.reward, extras.price), - ); - appendLegendListItem( - split, - createRewardPart("fees", "Fees", extras.totalFees, extras.reward, extras.price), - ); - bar.dataset.rewardBar = ""; - bar.append( - createRewardSegment("subsidy", subsidy, extras.reward), - createRewardSegment("fees", extras.totalFees, extras.reward), - ); - rewards.append(createRewardTotal("Rewards", extras.reward, extras.price), bar, split); - - rewards.addEventListener("pointerenter", (event) => { - setRewardPreview(rewards, getRewardKey(event.target)); - }, true); - rewards.addEventListener("pointerleave", () => setRewardPreview(rewards, null)); - rewards.addEventListener("pointerdown", (event) => { - setRewardPreview(rewards, getRewardKey(event.target)); - }); - rewards.addEventListener("pointerup", () => setRewardPreview(rewards, null)); - rewards.addEventListener("pointercancel", () => setRewardPreview(rewards, null)); - - return rewards; -} - -/** @param {Block} block */ -function createTransactionSummary(block) { - const { extras } = block; - const box = document.createElement("div"); - const transactions = document.createElement("div"); - const io = document.createElement("div"); - - box.dataset.blockBox = ""; - transactions.dataset.blockBox = "tx"; - io.dataset.blockIo = ""; - io.append( - createInlineBox("Input", extras.totalInputs.toLocaleString(), "input"), - createInlineBox("Output", extras.totalOutputs.toLocaleString(), "output"), - ); - transactions.append( - createInlineRow("Tx", [block.txCount.toLocaleString()]), - io, - ); - box.append( - createInlineRow("Block", [`${formatBytes(block.size)} · ${formatBlockFill(block)}`]), - transactions, - ); - - return box; -} - -/** - * @param {HTMLElement} parent - * @param {string} title - * @param {[string, string | Node | null | undefined][]} rows - * @param {Node[]} [children] - * @param {boolean} [showHeading] - */ -function appendGroup(parent, title, rows, children = [], showHeading = true) { - const visibleRows = rows.flatMap(([term, value]) => { - const row = createRow(term, value); - - return row ? [row] : []; - }); - - if (!visibleRows.length && !children.length) return; - - const section = document.createElement("section"); - const heading = document.createElement("h2"); - - section.dataset.group = groupName(title); - heading.textContent = title; - section.append(...(showHeading ? [heading] : []), ...children); - if (visibleRows.length) { - const list = document.createElement("dl"); - - list.append(...visibleRows); - section.append(list); - } - parent.append(section); -} +import { appendPane } from "./pane.js"; export function createBlockDetails() { const element = document.createElement("section"); - const header = document.createElement("header"); - const titleRow = document.createElement("div"); - const title = document.createElement("h1"); - const date = document.createElement("time"); - const meta = document.createElement("div"); - const hash = document.createElement("p"); - const price = createUsdAmount("output", 0, { - tone: "positive", - }); + const header = createBlockHeader(); const content = document.createElement("div"); element.id = "block-details"; element.hidden = true; - titleRow.dataset.blockTitle = ""; - date.dataset.blockDate = ""; - meta.dataset.blockMeta = ""; - hash.dataset.blockHashLine = ""; - titleRow.append(title, date); - meta.append(hash, price); - header.append(titleRow, meta); - element.append(header, content); + element.append(header.element, content); - /** @param {Block} block */ + /** @param {import("../../modules/brk-client/index.js").BlockInfoV1} block */ function update(block) { const extras = block.extras; element.hidden = false; - title.replaceChildren(...createTitle(block.height)); - date.dateTime = new Date(block.timestamp * 1_000).toISOString(); - date.textContent = formatDateTime(block.timestamp); - hash.replaceChildren(createHashElement(block.id)); - renderUsdAmount(price, extras.price, { - tone: "positive", - }); + header.update(block); for (const chart of content.querySelectorAll("[data-fee-chart]")) { chart.dispatchEvent(new Event("chart:destroy")); } content.textContent = ""; - appendGroup(content, "Mining", [], [createMinerSummary(block)], false); - - appendGroup(content, "Difficulty", [], [createDifficultySummary(block)], false); - - appendGroup(content, "Rewards", [], [createRewardSummary(extras)], false); - - appendGroup(content, "Block", [], [createTransactionSummary(block)], false); - - appendGroup(content, "Fees", [], [ - createFeeChart(extras.feeRange, extras.avgFeeRate, formatFeeRate), - ], false); + appendPane(content, "Mining", [createMinerPane(block)]); + appendPane(content, "Difficulty", [createDifficultyPane(block)]); + appendPane(content, "Rewards", [createRewardsPane(extras)]); + appendPane(content, "Block", [createTransactionPane(block)]); + appendPane(content, "Fees", [ + createFeeChart(extras.feeRange, extras.avgFeeRate), + ]); } return /** @type {const} */ ({ diff --git a/website_next/explore/block/miner.js b/website_next/explore/block/miner.js new file mode 100644 index 000000000..15d78715d --- /dev/null +++ b/website_next/explore/block/miner.js @@ -0,0 +1,57 @@ +import { createPoolLogo } from "../../pools/index.js"; + +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ + +/** @param {string} raw */ +function getCoinbaseMessage(raw) { + return (raw.match(/[\x20-\x7e]{2,}/g) ?? []) + .map((value) => value.trim()) + .filter((value) => /[A-Za-z0-9]/.test(value)) + .join(" · "); +} + +/** @param {string} raw */ +function createCoinbaseMessage(raw) { + const message = getCoinbaseMessage(raw); + + if (!message) return null; + + const element = document.createElement("p"); + + element.dataset.coinbaseMessage = ""; + element.textContent = message; + + return element; +} + +/** @param {Block} block */ +export function createMinerPane(block) { + const { pool } = block.extras; + const pane = document.createElement("div"); + const head = document.createElement("div"); + const identity = document.createElement("div"); + const title = document.createElement("div"); + const name = document.createElement("strong"); + const blockNumber = document.createElement("span"); + const slug = document.createElement("span"); + const logo = createPoolLogo(pool); + const coinbaseMessage = createCoinbaseMessage(block.extras.coinbaseSignatureAscii); + + pane.dataset.minerPane = ""; + head.dataset.minerHead = ""; + identity.dataset.minerIdentity = ""; + title.dataset.minerTitle = ""; + slug.dataset.minerSlug = ""; + logo.dataset.minerLogo = ""; + + name.textContent = pool.name; + // TODO: remove fallback after the server includes pool.blockNumber everywhere. + blockNumber.textContent = `#${(pool.blockNumber || 0).toLocaleString()}`; + slug.textContent = pool.slug; + title.append(name, blockNumber); + identity.append(title, slug); + head.append(identity, logo); + pane.append(head, ...(coinbaseMessage ? [coinbaseMessage] : [])); + + return pane; +} diff --git a/website_next/explore/block/pane.js b/website_next/explore/block/pane.js new file mode 100644 index 000000000..d7a9f9041 --- /dev/null +++ b/website_next/explore/block/pane.js @@ -0,0 +1,19 @@ +/** @param {string} title */ +function groupName(title) { + return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +/** + * @param {HTMLElement} parent + * @param {string} title + * @param {Node[]} children + */ +export function appendPane(parent, title, children) { + if (!children.length) return; + + const section = document.createElement("section"); + + section.dataset.group = groupName(title); + section.append(...children); + parent.append(section); +} diff --git a/website_next/explore/block/rewards.js b/website_next/explore/block/rewards.js new file mode 100644 index 000000000..0d464b836 --- /dev/null +++ b/website_next/explore/block/rewards.js @@ -0,0 +1,173 @@ +import { createBtcAmount, SATS_PER_BTC } from "../../btc/index.js"; +import { + appendLegendListItem, + createLegendItem, + createLegendList, +} from "../../legend/index.js"; +import { createUsdAmount } from "../../usd/index.js"; + +/** @typedef {import("../../modules/brk-client/index.js").BlockExtras} BlockExtras */ + +const REWARD_COLORS = /** @type {const} */ ({ + subsidy: "var(--orange)", + fees: "var(--green)", +}); + +/** @typedef {keyof typeof REWARD_COLORS} RewardType */ + +/** + * @param {number} sats + * @param {number} total + */ +function formatShare(sats, total) { + return `${((sats / total) * 100).toFixed(2)}%`; +} + +/** + * @param {number} sats + * @param {number} price + */ +function getSatsUsd(sats, price) { + return (sats / SATS_PER_BTC) * price; +} + +/** + * @param {number} sats + * @param {number} price + */ +function createSatsUsdAmount(sats, price) { + return createUsdAmount("span", getSatsUsd(sats, price)); +} + +/** + * @param {number} sats + * @param {number} total + * @param {number} price + */ +function createRewardDetail(sats, total, price) { + const detail = document.createDocumentFragment(); + + detail.append(createSatsUsdAmount(sats, price), " · ", formatShare(sats, total)); + + return detail; +} + +/** + * @param {RewardType} type + * @param {number} sats + * @param {number} total + */ +function createRewardSegment(type, sats, total) { + const segment = document.createElement("span"); + + segment.dataset.rewardSegment = type; + segment.dataset.rewardKey = type; + segment.style.setProperty("--share", `${(sats / total) * 100}%`); + + return segment; +} + +/** + * @param {RewardType} type + * @param {string} label + * @param {number} sats + * @param {number} total + * @param {number} price + */ +function createRewardPart(type, label, sats, total, price) { + const { button: part, value } = createLegendItem({ + label, + color: REWARD_COLORS[type], + ariaLabel: `Highlight ${label}`, + detail: createRewardDetail(sats, total, price), + }); + const amount = createBtcAmount("span", sats); + + part.dataset.rewardPart = type; + part.dataset.rewardKey = type; + value.replaceChildren(amount); + + return part; +} + +/** + * @param {string} label + * @param {number} sats + * @param {number} price + */ +function createRewardTotal(label, sats, price) { + const total = document.createElement("div"); + const name = document.createElement("span"); + const amount = createBtcAmount("strong", sats); + const usd = createSatsUsdAmount(sats, price); + + total.dataset.rewardTotal = ""; + name.textContent = label; + total.append(name, amount, usd); + + return total; +} + +/** @param {EventTarget | null} target */ +function getRewardKey(target) { + if (!(target instanceof HTMLElement)) return null; + + return target.closest("[data-reward-key]")?.getAttribute("data-reward-key") ?? null; +} + +/** + * @param {HTMLElement} rewards + * @param {string | null} activeKey + */ +function setRewardPreview(rewards, activeKey) { + for (const element of rewards.querySelectorAll("[data-reward-key]")) { + if (!(element instanceof HTMLElement)) continue; + + if (element.dataset.rewardKey === activeKey) { + element.dataset.preview = ""; + delete element.dataset.muted; + } else if (activeKey) { + element.dataset.muted = ""; + delete element.dataset.preview; + } else { + delete element.dataset.muted; + delete element.dataset.preview; + } + } +} + +/** @param {BlockExtras} extras */ +export function createRewardsPane(extras) { + const subsidy = extras.reward - extras.totalFees; + const rewards = document.createElement("div"); + const bar = document.createElement("div"); + const split = createLegendList({ fill: true }); + + rewards.dataset.rewardsPane = ""; + appendLegendListItem( + split, + createRewardPart("subsidy", "Subsidy", subsidy, extras.reward, extras.price), + ); + appendLegendListItem( + split, + createRewardPart("fees", "Fees", extras.totalFees, extras.reward, extras.price), + ); + bar.dataset.rewardBar = ""; + bar.append( + createRewardSegment("subsidy", subsidy, extras.reward), + createRewardSegment("fees", extras.totalFees, extras.reward), + ); + rewards.append(createRewardTotal("Rewards", extras.reward, extras.price), bar, split); + + rewards.addEventListener("pointerenter", (event) => { + setRewardPreview(rewards, getRewardKey(event.target)); + }, true); + rewards.addEventListener("pointerleave", () => setRewardPreview(rewards, null)); + rewards.addEventListener("pointerdown", (event) => { + setRewardPreview(rewards, getRewardKey(event.target)); + }); + rewards.addEventListener("pointerup", () => setRewardPreview(rewards, null)); + rewards.addEventListener("pointercancel", () => setRewardPreview(rewards, null)); + + return rewards; +} diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css index 93eef0353..04874824a 100644 --- a/website_next/explore/block/style.css +++ b/website_next/explore/block/style.css @@ -10,11 +10,11 @@ line-height: var(--line-height-sm); scrollbar-width: none; - .dim { + [data-dim] { opacity: 0.5; } - :is(h1, h2, p, dl, dd) { + :is(h1, p) { margin: 0; } @@ -42,7 +42,7 @@ line-height: 1; } - .title-label { + [data-title-label] { font-family: var(--font-serif); font-size: 2.5rem; font-style: italic; @@ -50,7 +50,7 @@ text-transform: lowercase; } - .title-height { + [data-title-height] { color: var(--gray); font-size: var(--font-size-lg); line-height: var(--line-height-lg); @@ -86,10 +86,6 @@ gap: 0.75rem; min-width: 0; - &[data-group="overview"] { - grid-column: 1 / -1; - } - &[data-group="mining"] { --section-color: var(--orange); @@ -160,10 +156,11 @@ text-overflow: ellipsis; white-space: nowrap; } - } - &:is([data-group="mining"], [data-group="difficulty"]) { + &[data-group="difficulty"] { + --section-color: var(--orange); + [data-metric-list] { display: grid; gap: 0.5rem; @@ -247,31 +244,9 @@ } } - &[data-group="difficulty"] { - --section-color: var(--orange); - } - &[data-group="block"] { --section-color: var(--cyan); - h2 { - color: var(--section-color); - } - } - - &[data-group="rewards"] { - --section-color: var(--orange); - } - - &[data-group="rewards"] { - [data-stat-box] { - display: grid; - gap: 0.5rem; - min-width: 0; - } - } - - &[data-group="block"] { [data-block-box] { display: grid; gap: 0.5rem; @@ -365,6 +340,14 @@ } &[data-group="rewards"] { + --section-color: var(--orange); + + [data-rewards-pane] { + display: grid; + gap: 0.5rem; + min-width: 0; + } + [data-reward-total] { display: grid; gap: 0.125rem; @@ -417,62 +400,11 @@ } &[data-group="fees"] { - h2 { - color: var(--green); - } - figure[data-fee-chart] { --chart-xy-height: 7.5rem; } } } - - h2 { - color: var(--gray); - font-family: var(--font-mono); - font-size: var(--font-size-xs); - font-weight: 450; - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - dl { - display: grid; - - > div { - display: grid; - grid-template-columns: minmax(6rem, 0.36fr) minmax(0, 1fr); - gap: 0.75rem; - padding: 0.35rem 0; - border-bottom: 1px solid - color-mix(in oklch, var(--gray) 12%, transparent); - - &:first-child { - padding-top: 0; - } - - &:last-child { - padding-bottom: 0; - border-bottom: 0; - } - } - } - - dt { - color: var(--gray); - } - - dd { - min-width: 0; - overflow-wrap: anywhere; - text-align: right; - } - - code { - font-family: var(--font-mono); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - } } @media (max-width: 48rem) { @@ -484,18 +416,5 @@ > div { grid-template-columns: minmax(0, 1fr); } - - section[data-group="overview"] { - grid-column: auto; - } - - dl > div { - grid-template-columns: minmax(0, 1fr); - gap: 0.15rem; - } - - dd { - text-align: left; - } } } diff --git a/website_next/explore/block/transactions.js b/website_next/explore/block/transactions.js new file mode 100644 index 000000000..5151ec204 --- /dev/null +++ b/website_next/explore/block/transactions.js @@ -0,0 +1,72 @@ +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ + +const MAX_BLOCK_WEIGHT = 4_000_000; + +/** @param {number} bytes */ +function formatBytes(bytes) { + return bytes >= 1_000_000 + ? `${(bytes / 1_000_000).toFixed(2)} MB` + : `${bytes.toLocaleString()} B`; +} + +/** + * @param {string} label + * @param {(string | Node)[]} values + */ +function createInlineRow(label, values) { + const row = document.createElement("div"); + const name = document.createElement("span"); + const data = document.createElement("strong"); + + row.dataset.inlineRow = ""; + name.textContent = label; + data.append(...values); + row.append(name, data); + + return row; +} + +/** + * @param {string} label + * @param {string | Node} value + * @param {string} [type] + */ +function createInlineBox(label, value, type = "inline") { + const box = document.createElement("div"); + + box.dataset.blockBox = type; + box.append(createInlineRow(label, [value])); + + return box; +} + +/** @param {Block} block */ +function formatBlockFill(block) { + return `${((block.weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`; +} + +/** @param {Block} block */ +export function createTransactionPane(block) { + const { extras } = block; + const box = document.createElement("div"); + const transactions = document.createElement("div"); + const io = document.createElement("div"); + + box.dataset.blockBox = ""; + transactions.dataset.blockBox = "tx"; + io.dataset.blockIo = ""; + io.append( + createInlineBox("Input", extras.totalInputs.toLocaleString(), "input"), + createInlineBox("Output", extras.totalOutputs.toLocaleString(), "output"), + ); + transactions.append( + createInlineRow("Tx", [block.txCount.toLocaleString()]), + io, + ); + box.append( + createInlineRow("Block", [`${formatBytes(block.size)} · ${formatBlockFill(block)}`]), + transactions, + ); + + return box; +} diff --git a/website_next/explore/chain/block-cube.js b/website_next/explore/chain/block-cube.js new file mode 100644 index 000000000..3f4ae7525 --- /dev/null +++ b/website_next/explore/chain/block-cube.js @@ -0,0 +1,178 @@ +import { createPoolLogo, getPoolDisplayName } from "../../pools/index.js"; +import { formatFeeRate } from "../../utils/fee-rate.js"; +import { createCubeButton, createCubeDiv } from "./cube/index.js"; +import { onPlainClick } from "./events.js"; +import { + createHeightElement, + dim, + formatHHMM, + formatShortDate, +} from "./format.js"; + +/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */ +/** @typedef {import("../../modules/brk-client/index.js").MempoolBlock} MempoolBlock */ + +export function createPlaceholderCube() { + const cube = document.createElement("div"); + + cube.dataset.cube = ""; + cube.dataset.placeholder = ""; + + return cube; +} + +/** + * @param {Block} block + * @param {(cube: HTMLButtonElement) => void} onSelect + */ +export function createEnteringConfirmedCube(block, onSelect) { + const cube = createConfirmedCube(block, onSelect); + + markCubeEntering(cube); + + return cube; +} + +/** + * @param {Block} block + * @param {(cube: HTMLButtonElement) => void} onSelect + */ +function createConfirmedCube(block, onSelect) { + const { pool, medianFee, feeRange, virtualSize } = block.extras; + const cube = createCubeButton(Math.min(1, virtualSize / 1_000_000)); + + cube.element.dataset.hash = block.id; + cube.element.dataset.height = String(block.height); + cube.element.dataset.timestamp = String(block.timestamp); + cube.element.title = `Block ${block.height.toLocaleString()}`; + onPlainClick(cube.element, () => onSelect(cube.element)); + + const date = document.createElement("p"); + const time = document.createElement("p"); + const [hh, mm] = formatHHMM(block.timestamp); + date.textContent = formatShortDate(block.timestamp); + time.append(hh, dim(":"), mm); + cube.topFace.append(date, time); + + const height = document.createElement("p"); + height.dataset.cubeHeight = ""; + height.append(createHeightElement(block.height)); + + const poolElement = document.createElement("div"); + const logo = createPoolLogo(pool); + const name = document.createElement("span"); + poolElement.dataset.cubePool = ""; + name.textContent = getPoolDisplayName(pool.name); + poolElement.append(logo, name); + cube.rightFace.append(height, poolElement); + + const fees = document.createElement("div"); + const median = document.createElement("p"); + const range = document.createElement("p"); + const unit = document.createElement("p"); + fees.dataset.cubeFees = ""; + median.append(dim("~"), formatFeeRate(medianFee)); + range.append( + formatFeeRate(feeRange[0]), + dim("-"), + formatFeeRate(feeRange[6]), + ); + unit.dataset.dim = ""; + unit.textContent = "sat/vB"; + fees.append(median, range, unit); + cube.leftFace.append(fees); + + return cube.element; +} + +/** @param {HTMLElement} cube */ +function markCubeEntering(cube) { + cube.dataset.enter = ""; + cube.addEventListener( + "animationend", + () => { + cube.removeAttribute("data-enter"); + }, + { once: true }, + ); +} + +export function createProjectedCube() { + const cube = createCubeDiv(); + const date = document.createTextNode(""); + const hh = document.createTextNode(""); + const mm = document.createTextNode(""); + const txs = document.createTextNode(""); + const txsUnit = document.createTextNode(""); + const median = document.createTextNode(""); + const rangeLo = document.createTextNode(""); + const rangeHi = document.createTextNode(""); + + const dateElement = document.createElement("p"); + const timeElement = document.createElement("p"); + const txsElement = document.createElement("p"); + const txsUnitElement = document.createElement("p"); + const medianElement = document.createElement("p"); + const rangeElement = document.createElement("p"); + const unitElement = document.createElement("p"); + + cube.element.dataset.projected = ""; + dateElement.append(date); + timeElement.append(hh, dim(":"), mm); + cube.topFace.append(dateElement, timeElement); + + txsElement.append(txs); + txsUnitElement.dataset.dim = ""; + txsUnitElement.append(txsUnit); + cube.rightFace.append(txsElement, txsUnitElement); + + medianElement.append(dim("~"), median); + rangeElement.append(rangeLo, dim("-"), rangeHi); + unitElement.dataset.dim = ""; + unitElement.textContent = "sat/vB"; + cube.leftFace.append(medianElement, rangeElement, unitElement); + + return { + ...cube, + parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi }, + }; +} + +/** @param {ReturnType} cube @param {MempoolBlock} block */ +export function updateProjectedCube(cube, block) { + cube.element.style.setProperty( + "--fill", + String(Math.min(1, block.blockVSize / 1_000_000)), + ); + + cube.parts.txs.nodeValue = block.nTx.toLocaleString(); + cube.parts.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs"; + cube.parts.median.nodeValue = formatFeeRate(block.medianFee); + cube.parts.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]); + cube.parts.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]); +} + +/** @param {ReturnType} cube @param {number} timestamp */ +export function updateProjectedTime(cube, timestamp) { + const [hh, mm] = formatHHMM(timestamp); + + cube.parts.date.nodeValue = formatShortDate(timestamp); + cube.parts.hh.nodeValue = hh; + cube.parts.mm.nodeValue = mm; +} + +/** @param {HTMLElement} cube */ +export function setConfirmedInterval(cube) { + const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling); + if (!prev?.dataset.timestamp) return; + + cube.style.setProperty( + "--block-interval", + String( + Math.max( + 0, + Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp), + ), + ), + ); +} diff --git a/website_next/explore/chain/cube/index.js b/website_next/explore/chain/cube/index.js index 9b5020d1e..7d77ba333 100644 --- a/website_next/explore/chain/cube/index.js +++ b/website_next/explore/chain/cube/index.js @@ -27,7 +27,7 @@ function populateCube(element, fill) { const rightFace = createFace("face-text", "right"); const leftFace = createFace("face-text", "left"); - element.classList.add("cube"); + element.dataset.cube = ""; element.style.setProperty("--fill", String(fill)); element.append( createFace("glass", "bottom"), diff --git a/website_next/explore/chain/cube/style.css b/website_next/explore/chain/cube/style.css index 6926f374d..23cf38cc2 100644 --- a/website_next/explore/chain/cube/style.css +++ b/website_next/explore/chain/cube/style.css @@ -4,11 +4,11 @@ --cube-empty-alpha: 0.4; --face-step: 0.033; - &:not(.loading) .cube[data-enter] { + &:not([data-loading]) [data-cube][data-enter] { animation: confirmed-cube-enter 180ms ease-out both; } - .cube { + [data-cube] { --cube-width: calc(var(--cube-size) * 2 * var(--iso-scale)); --cube-height: calc(var(--cube-size) * 2); @@ -69,26 +69,26 @@ } &:is(button):active, - &.selected { + &[data-selected] { color: var(--black); --face-color-base: var(--orange); } - &[data-press]:not(.selected) { + &[data-press]:not([data-selected]) { color: var(--background-color); --face-color-base: var(--inv-border-color); } &:is(button):active, - &.selected, - &[data-press]:not(.selected) { + &[data-selected], + &[data-press]:not([data-selected]) { --face-top: var(--state-face-top); --face-right: var(--state-face-right); --face-left: var(--state-face-left); --face-bottom: var(--state-face-bottom); } - &.projected { + &[data-projected] { animation: projected-cube-pulse 4s ease-in-out infinite; } @@ -96,7 +96,7 @@ visibility: hidden; } - &.skeleton .face-text { + &[data-skeleton] .face-text { visibility: hidden; } diff --git a/website_next/explore/chain/edge.js b/website_next/explore/chain/edge.js new file mode 100644 index 000000000..469ca149f --- /dev/null +++ b/website_next/explore/chain/edge.js @@ -0,0 +1,22 @@ +import { onPlainClick } from "./events.js"; + +/** + * @param {"tip"} name + * @param {string} label + * @param {string} mobileLabel + * @param {string} title + * @param {() => void} handler + */ +export function createEdgeButton(name, label, mobileLabel, title, handler) { + const button = document.createElement("button"); + + button.type = "button"; + button.title = title; + button.ariaLabel = title; + button.dataset.edge = name; + button.dataset.mobileLabel = mobileLabel; + button.textContent = label; + onPlainClick(button, handler); + + return button; +} diff --git a/website_next/explore/chain/events.js b/website_next/explore/chain/events.js new file mode 100644 index 000000000..626babef5 --- /dev/null +++ b/website_next/explore/chain/events.js @@ -0,0 +1,11 @@ +import { isPlainLeftClick } from "../../utils/event.js"; + +/** @param {HTMLElement} element @param {() => void} handler */ +export function onPlainClick(element, handler) { + element.addEventListener("click", (event) => { + if (!(event instanceof MouseEvent) || !isPlainLeftClick(event)) return; + + event.preventDefault(); + handler(); + }); +} diff --git a/website_next/explore/chain/format.js b/website_next/explore/chain/format.js new file mode 100644 index 000000000..4c0fa8fbf --- /dev/null +++ b/website_next/explore/chain/format.js @@ -0,0 +1,55 @@ +const MONTHS = /** @type {const} */ ([ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]); + +/** @param {string} text */ +export function dim(text) { + const element = document.createElement("span"); + + element.dataset.dim = ""; + element.textContent = text; + + return element; +} + +/** @param {number} height */ +export function createHeightElement(height) { + const container = document.createElement("span"); + const prefix = document.createElement("span"); + const value = document.createElement("span"); + + prefix.dataset.dim = ""; + prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; + value.textContent = String(height); + container.append(prefix, value); + + return container; +} + +/** @param {number} unixSeconds */ +export function formatShortDate(unixSeconds) { + const date = new Date(unixSeconds * 1_000); + + return `${MONTHS[date.getMonth()]} ${date.getDate()}`; +} + +/** @param {number} unixSeconds */ +export function formatHHMM(unixSeconds) { + const date = new Date(unixSeconds * 1_000); + + return [ + String(date.getHours()).padStart(2, "0"), + String(date.getMinutes()).padStart(2, "0"), + ]; +} diff --git a/website_next/explore/chain/index.js b/website_next/explore/chain/index.js index e261ed726..5acb042a2 100644 --- a/website_next/explore/chain/index.js +++ b/website_next/explore/chain/index.js @@ -1,7 +1,24 @@ -import { createPoolLogo, getPoolDisplayName } from "../../pools/index.js"; import { brk } from "../../utils/client.js"; -import { isPlainLeftClick } from "../../utils/event.js"; -import { createCubeButton, createCubeDiv } from "./cube/index.js"; +import { + createEnteringConfirmedCube, + createPlaceholderCube, + createProjectedCube, + setConfirmedInterval, + updateProjectedCube, + updateProjectedTime, +} from "./block-cube.js"; +import { createEdgeButton } from "./edge.js"; +import { + distanceFromViewport, + findVisibleConfirmedHeight, + isHorizontalLayout, + olderRemaining, + olderRunway, + olderWheelDelta, + preserveScrollPosition, + scrollToElement, +} from "./scroll.js"; +import { transitionMs } from "./transition.js"; const BLOCK_BATCH_SIZE = 15; const EDGE_LOAD_DISTANCE = 50; @@ -10,107 +27,11 @@ const POLL_INTERVAL = 1_000; const PROJECTED_LIMIT = 8; const TARGET_BLOCK_SECONDS = 600; const TIP_BLOCK_THRESHOLD = 10; -const MONTHS = /** @type {const} */ ([ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]); /** @typedef {Awaited>[number]} Block */ /** @typedef {Awaited>[number]} MempoolBlock */ /** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */ -/** @param {number} rate */ -function formatFeeRate(rate) { - if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`; - if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`; - if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`; - if (rate >= 100) return Math.round(rate).toLocaleString(); - if (rate >= 10) return rate.toFixed(1); - return rate.toFixed(2); -} - -/** @param {number} height */ -function createHeightElement(height) { - const container = document.createElement("span"); - const prefix = document.createElement("span"); - const value = document.createElement("span"); - - prefix.classList.add("dim"); - prefix.textContent = `#${"0".repeat(Math.max(0, 7 - String(height).length))}`; - value.textContent = String(height); - container.append(prefix, value); - - return container; -} - -/** @param {HTMLElement} element @param {() => void} handler */ -function onPlainClick(element, handler) { - element.addEventListener("click", (event) => { - if (!(event instanceof MouseEvent) || !isPlainLeftClick(event)) return; - - event.preventDefault(); - handler(); - }); -} - -/** @param {string} text @param {string} [className] */ -function span(text, className) { - const element = document.createElement("span"); - - if (className) element.classList.add(className); - element.textContent = text; - - return element; -} - -/** @param {number} unixSeconds */ -function formatShortDate(unixSeconds) { - const date = new Date(unixSeconds * 1_000); - - return `${MONTHS[date.getMonth()]} ${date.getDate()}`; -} - -/** @param {number} unixSeconds */ -function formatHHMM(unixSeconds) { - const date = new Date(unixSeconds * 1_000); - - return [ - String(date.getHours()).padStart(2, "0"), - String(date.getMinutes()).padStart(2, "0"), - ]; -} - -/** - * @param {"tip"} className - * @param {string} label - * @param {string} mobileLabel - * @param {string} title - * @param {() => void} handler - */ -function createEdgeButton(className, label, mobileLabel, title, handler) { - const button = document.createElement("button"); - - button.classList.add("edge", className); - button.type = "button"; - button.title = title; - button.ariaLabel = title; - button.dataset.mobileLabel = mobileLabel; - button.textContent = label; - onPlainClick(button, handler); - - return button; -} - /** * @param {{ onSelect?: (block: Block) => void }} [options] */ @@ -124,8 +45,8 @@ export function createChain({ onSelect = () => {} } = {}) { element.id = "chain"; setTipVisible(false); - scrollElement.classList.add("scroll"); - blocksElement.classList.add("blocks"); + scrollElement.dataset.chainScroll = ""; + blocksElement.dataset.chainBlocks = ""; scrollElement.append(blocksElement); element.append(tipButton, scrollElement); @@ -192,7 +113,7 @@ export function createChain({ onSelect = () => {} } = {}) { } function deselectCube() { - if (selectedCube) selectedCube.classList.remove("selected"); + if (selectedCube) delete selectedCube.dataset.selected; selectedCube = null; } @@ -207,7 +128,7 @@ export function createChain({ onSelect = () => {} } = {}) { jumping = true; - element.classList.add("jumping"); + element.dataset.jumping = ""; element.addEventListener("transitionend", finishJumpToTip); jumpTimeout = window.setTimeout( finishJumpToTip, @@ -236,43 +157,10 @@ export function createChain({ onSelect = () => {} } = {}) { } element.removeEventListener("transitionend", finishJumpToTip); - element.classList.remove("jumping"); + delete element.dataset.jumping; jumping = false; } - /** - * @param {Element} element - * @param {string} property - */ - function transitionMs(element, property) { - const style = getComputedStyle(element); - const properties = style.transitionProperty.split(",").map((part) => { - return part.trim(); - }); - const durations = parseCssTimes(style.transitionDuration); - const delays = parseCssTimes(style.transitionDelay); - const index = properties.findIndex((part) => { - return part === property || part === "all"; - }); - - if (index < 0) return 0; - - const duration = durations[index] ?? durations.at(-1) ?? 0; - const delay = delays[index] ?? delays.at(-1) ?? 0; - - return duration + delay; - } - - /** @param {string} value */ - function parseCssTimes(value) { - return value.split(",").map((part) => { - const time = part.trim(); - const amount = Number.parseFloat(time); - - return time.endsWith("ms") ? amount : amount * 1_000; - }); - } - /** * @param {HTMLButtonElement} cube * @param {{ scroll?: "smooth" | "instant" }} [options] @@ -281,7 +169,7 @@ export function createChain({ onSelect = () => {} } = {}) { if (cube !== selectedCube) { deselectCube(); selectedCube = cube; - cube.classList.add("selected"); + cube.dataset.selected = ""; } const hash = cube.dataset.hash; @@ -294,52 +182,8 @@ export function createChain({ onSelect = () => {} } = {}) { } } - /** - * @param {Element} target - * @param {"smooth" | "instant"} behavior - */ - function scrollToElement(target, behavior) { - target.scrollIntoView({ - behavior, - block: "center", - inline: "center", - }); - } - - /** - * @param {Element | null | undefined} anchor - * @param {DOMRect | undefined} anchorRect - */ - function preserveScrollPosition(anchor, anchorRect) { - if (!anchor || !anchorRect) return; - - const rect = anchor.getBoundingClientRect(); - - scrollElement.scrollTop += rect.top - anchorRect.top; - scrollElement.scrollLeft += rect.left - anchorRect.left; - } - function isHorizontal() { - return getComputedStyle(blocksElement).flexDirection.startsWith("row"); - } - - /** @param {boolean} horizontal */ - function olderRemaining(horizontal) { - return horizontal - ? scrollElement.scrollWidth - - scrollElement.clientWidth - - scrollElement.scrollLeft - : scrollElement.scrollHeight - - scrollElement.clientHeight - - scrollElement.scrollTop; - } - - /** @param {boolean} horizontal */ - function olderRunway(horizontal) { - return ( - (horizontal ? scrollElement.clientWidth : scrollElement.clientHeight) * - OLDER_RESERVE_VIEWPORTS - ); + return isHorizontalLayout(blocksElement); } /** @param {number} [delta] */ @@ -347,12 +191,13 @@ export function createChain({ onSelect = () => {} } = {}) { if (!active || oldestReservedHeight <= 0) return; const horizontal = isHorizontal(); - const runway = olderRunway(horizontal) + delta; - let remaining = olderRemaining(horizontal); + const runway = + olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta; + let remaining = olderRemaining(scrollElement, horizontal); while (remaining < runway) { if (!reserveOlderBatch()) return; - remaining = olderRemaining(horizontal); + remaining = olderRemaining(scrollElement, horizontal); } } @@ -383,10 +228,8 @@ export function createChain({ onSelect = () => {} } = {}) { const placeholders = /** @type {HTMLElement[]} */ ([]); for (let i = 0; i < count; i++) { - const cube = document.createElement("div"); + const cube = createPlaceholderCube(); - cube.classList.add("cube"); - cube.dataset.placeholder = ""; placeholders.push(cube); fragment.append(cube); } @@ -413,6 +256,13 @@ export function createChain({ onSelect = () => {} } = {}) { return true; } + /** @param {Block} block */ + function createKnownEnteringConfirmedCube(block) { + blocksByHash.set(block.id, block); + + return createEnteringConfirmedCube(block, selectCube); + } + /** @param {Block[]} blocks */ function appendNewerBlocks(blocks) { if (!blocks.length) return false; @@ -424,7 +274,7 @@ export function createChain({ onSelect = () => {} } = {}) { const block = blocks[i]; if (block.height > newestHeight) { - appendConfirmed(createEnteringConfirmedCube(block)); + appendConfirmed(createKnownEnteringConfirmedCube(block)); } else { blocksByHash.set(block.id, block); } @@ -435,7 +285,7 @@ export function createChain({ onSelect = () => {} } = {}) { updateTipCube(); refreshProjected(); - preserveScrollPosition(anchor, anchorRect); + preserveScrollPosition(scrollElement, anchor, anchorRect); syncTipVisibility(); @@ -452,7 +302,7 @@ export function createChain({ onSelect = () => {} } = {}) { clear(); for (const block of blocks) { - prependConfirmed(createEnteringConfirmedCube(block)); + prependConfirmed(createKnownEnteringConfirmedCube(block)); } newestHeight = blocks[0].height; @@ -504,10 +354,12 @@ export function createChain({ onSelect = () => {} } = {}) { } for (const cube of blocksElement.children) { - if (!cube.classList.contains("projected")) cube.classList.add("skeleton"); + if (!cube.hasAttribute("data-projected")) { + cube.setAttribute("data-skeleton", ""); + } } - element.classList.add("loading"); + element.dataset.loading = ""; try { const height = await resolveHeight(hashOrHeight); @@ -519,7 +371,7 @@ export function createChain({ onSelect = () => {} } = {}) { console.error("explore chain load:", error); } } finally { - element.classList.remove("loading"); + delete element.dataset.loading; } } @@ -580,7 +432,7 @@ export function createChain({ onSelect = () => {} } = {}) { return; } - const cubes = [...blocks].reverse().map(createEnteringConfirmedCube); + const cubes = [...blocks].reverse().map(createKnownEnteringConfirmedCube); for (let i = 0; i < batch.placeholders.length; i++) { const cube = cubes[i]; @@ -633,93 +485,6 @@ export function createChain({ onSelect = () => {} } = {}) { } } - /** @param {HTMLElement} cube */ - function markCubeEntering(cube) { - cube.dataset.enter = ""; - cube.addEventListener( - "animationend", - () => { - cube.removeAttribute("data-enter"); - }, - { once: true }, - ); - } - - /** @param {Block} block */ - function createEnteringConfirmedCube(block) { - const cube = createConfirmedCube(block); - - markCubeEntering(cube); - - return cube; - } - - /** @param {Block} block */ - function createConfirmedCube(block) { - const { pool, medianFee, feeRange, virtualSize } = block.extras; - const cube = createCubeButton(Math.min(1, virtualSize / 1_000_000)); - - cube.element.dataset.hash = block.id; - cube.element.dataset.height = String(block.height); - cube.element.dataset.timestamp = String(block.timestamp); - cube.element.title = `Block ${block.height.toLocaleString()}`; - blocksByHash.set(block.id, block); - onPlainClick(cube.element, () => selectCube(cube.element)); - - const date = document.createElement("p"); - const time = document.createElement("p"); - const [hh, mm] = formatHHMM(block.timestamp); - date.textContent = formatShortDate(block.timestamp); - time.append(hh, span(":", "dim"), mm); - cube.topFace.append(date, time); - - const height = document.createElement("p"); - height.classList.add("height"); - height.append(createHeightElement(block.height)); - - const poolElement = document.createElement("div"); - const logo = createPoolLogo(pool); - const name = document.createElement("span"); - poolElement.classList.add("pool"); - name.textContent = getPoolDisplayName(pool.name); - poolElement.append(logo, name); - cube.rightFace.append(height, poolElement); - - const fees = document.createElement("div"); - const median = document.createElement("p"); - const range = document.createElement("p"); - const unit = document.createElement("p"); - fees.classList.add("fees"); - median.append(span("~", "dim"), formatFeeRate(medianFee)); - range.append( - formatFeeRate(feeRange[0]), - span("-", "dim"), - formatFeeRate(feeRange[6]), - ); - unit.classList.add("dim"); - unit.textContent = "sat/vB"; - fees.append(median, range, unit); - cube.leftFace.append(fees); - - return cube.element; - } - - /** @param {HTMLElement} cube */ - function setConfirmedInterval(cube) { - const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling); - if (!prev?.dataset.timestamp) return; - - cube.style.setProperty( - "--block-interval", - String( - Math.max( - 0, - Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp), - ), - ), - ); - } - /** @param {HTMLButtonElement} cube */ function prependConfirmed(cube) { const oldFirst = /** @type {HTMLElement | null} */ ( @@ -758,61 +523,6 @@ export function createChain({ onSelect = () => {} } = {}) { refreshProjected(); } - function createProjectedCube() { - const cube = createCubeDiv(); - const date = document.createTextNode(""); - const hh = document.createTextNode(""); - const mm = document.createTextNode(""); - const txs = document.createTextNode(""); - const txsUnit = document.createTextNode(""); - const median = document.createTextNode(""); - const rangeLo = document.createTextNode(""); - const rangeHi = document.createTextNode(""); - - const dateElement = document.createElement("p"); - const timeElement = document.createElement("p"); - const txsElement = document.createElement("p"); - const txsUnitElement = document.createElement("p"); - const medianElement = document.createElement("p"); - const rangeElement = document.createElement("p"); - const unitElement = document.createElement("p"); - - cube.element.classList.add("projected"); - dateElement.append(date); - timeElement.append(hh, span(":", "dim"), mm); - cube.topFace.append(dateElement, timeElement); - - txsElement.append(txs); - txsUnitElement.classList.add("dim"); - txsUnitElement.append(txsUnit); - cube.rightFace.append(txsElement, txsUnitElement); - - medianElement.append(span("~", "dim"), median); - rangeElement.append(rangeLo, span("-", "dim"), rangeHi); - unitElement.classList.add("dim"); - unitElement.textContent = "sat/vB"; - cube.leftFace.append(medianElement, rangeElement, unitElement); - - return { - ...cube, - parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi }, - }; - } - - /** @param {ReturnType} cube @param {MempoolBlock} block */ - function updateProjectedCube(cube, block) { - cube.element.style.setProperty( - "--fill", - String(Math.min(1, block.blockVSize / 1_000_000)), - ); - - cube.parts.txs.nodeValue = block.nTx.toLocaleString(); - cube.parts.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs"; - cube.parts.median.nodeValue = formatFeeRate(block.medianFee); - cube.parts.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]); - cube.parts.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]); - } - function refreshProjected() { if (!projectedCubes.length || !newestTimestamp) return; @@ -824,15 +534,12 @@ export function createChain({ onSelect = () => {} } = {}) { const cube = projectedCubes[i]; const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS; const timestamp = now + i * TARGET_BLOCK_SECONDS; - const [hh, mm] = formatHHMM(timestamp); if (updateLayout) { cube.element.style.setProperty("--block-interval", String(interval)); } - cube.parts.date.nodeValue = formatShortDate(timestamp); - cube.parts.hh.nodeValue = hh; - cube.parts.mm.nodeValue = mm; + updateProjectedTime(cube, timestamp); } } @@ -858,7 +565,7 @@ export function createChain({ onSelect = () => {} } = {}) { return; } - const visibleHeight = findVisibleConfirmedHeight(); + const visibleHeight = findVisibleConfirmedHeight(scrollElement, blocksElement); if (projectedCubes.some(({ element }) => isElementVisible(element))) { setTipVisible(false); return; @@ -872,65 +579,25 @@ export function createChain({ onSelect = () => {} } = {}) { } /** @param {Element} element */ - function distanceFromViewport(element) { - const viewport = scrollElement.getBoundingClientRect(); - const rect = element.getBoundingClientRect(); - const horizontal = isHorizontal(); - - if (horizontal) { - if (rect.left > viewport.right) return rect.left - viewport.right; - if (rect.right < viewport.left) return viewport.left - rect.right; - return 0; - } - - if (rect.top > viewport.bottom) return rect.top - viewport.bottom; - if (rect.bottom < viewport.top) return viewport.top - rect.bottom; - return 0; + function cubeDistanceFromViewport(element) { + return distanceFromViewport(scrollElement, element, isHorizontal()); } /** @param {Element} element */ function isElementVisible(element) { - return distanceFromViewport(element) === 0; + return cubeDistanceFromViewport(element) === 0; } function shouldLoadNewer() { const cube = newestConfirmedCube(); - return cube != null && distanceFromViewport(cube) <= EDGE_LOAD_DISTANCE; - } - - function findVisibleConfirmedHeight() { - const viewport = scrollElement.getBoundingClientRect(); - const x = (viewport.left + viewport.right) / 2; - const y = (viewport.top + viewport.bottom) / 2; - - for (const element of document.elementsFromPoint(x, y)) { - const cube = element.closest(".cube[data-height]"); - - if ( - cube instanceof HTMLElement && - blocksElement.contains(cube) && - !cube.classList.contains("projected") - ) { - return Number(cube.dataset.height); - } - } - - return null; - } - - /** @param {WheelEvent} event */ - function olderWheelDelta(event) { - return Math.max( - 0, - isHorizontal() ? Math.max(event.deltaX, event.deltaY) : event.deltaY, - ); + return cube != null && cubeDistanceFromViewport(cube) <= EDGE_LOAD_DISTANCE; } scrollElement.addEventListener( "wheel", (event) => { - reserveOlderRunway(olderWheelDelta(event)); + reserveOlderRunway(olderWheelDelta(event, isHorizontal())); }, { passive: true }, ); diff --git a/website_next/explore/chain/scroll.js b/website_next/explore/chain/scroll.js new file mode 100644 index 000000000..0b1980c63 --- /dev/null +++ b/website_next/explore/chain/scroll.js @@ -0,0 +1,105 @@ +/** + * @param {Element} target + * @param {"smooth" | "instant"} behavior + */ +export function scrollToElement(target, behavior) { + target.scrollIntoView({ + behavior, + block: "center", + inline: "center", + }); +} + +/** + * @param {HTMLElement} scrollElement + * @param {Element | null | undefined} anchor + * @param {DOMRect | undefined} anchorRect + */ +export function preserveScrollPosition(scrollElement, anchor, anchorRect) { + if (!anchor || !anchorRect) return; + + const rect = anchor.getBoundingClientRect(); + + scrollElement.scrollTop += rect.top - anchorRect.top; + scrollElement.scrollLeft += rect.left - anchorRect.left; +} + +/** @param {HTMLElement} blocksElement */ +export function isHorizontalLayout(blocksElement) { + return getComputedStyle(blocksElement).flexDirection.startsWith("row"); +} + +/** @param {HTMLElement} scrollElement @param {boolean} horizontal */ +export function olderRemaining(scrollElement, horizontal) { + return horizontal + ? scrollElement.scrollWidth - + scrollElement.clientWidth - + scrollElement.scrollLeft + : scrollElement.scrollHeight - + scrollElement.clientHeight - + scrollElement.scrollTop; +} + +/** + * @param {HTMLElement} scrollElement + * @param {boolean} horizontal + * @param {number} viewports + */ +export function olderRunway(scrollElement, horizontal, viewports) { + return ( + (horizontal ? scrollElement.clientWidth : scrollElement.clientHeight) * + viewports + ); +} + +/** @param {WheelEvent} event @param {boolean} horizontal */ +export function olderWheelDelta(event, horizontal) { + return Math.max( + 0, + horizontal ? Math.max(event.deltaX, event.deltaY) : event.deltaY, + ); +} + +/** + * @param {HTMLElement} scrollElement + * @param {Element} element + * @param {boolean} horizontal + */ +export function distanceFromViewport(scrollElement, element, horizontal) { + const viewport = scrollElement.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); + + if (horizontal) { + if (rect.left > viewport.right) return rect.left - viewport.right; + if (rect.right < viewport.left) return viewport.left - rect.right; + return 0; + } + + if (rect.top > viewport.bottom) return rect.top - viewport.bottom; + if (rect.bottom < viewport.top) return viewport.top - rect.bottom; + return 0; +} + +/** + * @param {HTMLElement} scrollElement + * @param {HTMLElement} blocksElement + */ +export function findVisibleConfirmedHeight(scrollElement, blocksElement) { + const viewport = scrollElement.getBoundingClientRect(); + const x = (viewport.left + viewport.right) / 2; + const y = (viewport.top + viewport.bottom) / 2; + + for (const element of document.elementsFromPoint(x, y)) { + const cube = element.closest("[data-cube][data-height]"); + + if ( + cube instanceof HTMLElement && + blocksElement.contains(cube) && + !cube.hasAttribute("data-projected") + ) { + return Number(cube.dataset.height); + } + } + + return null; +} diff --git a/website_next/explore/chain/style.css b/website_next/explore/chain/style.css index 59afb4cfe..95854033c 100644 --- a/website_next/explore/chain/style.css +++ b/website_next/explore/chain/style.css @@ -18,17 +18,17 @@ opacity: 1; transition: opacity 200ms ease; - &.loading, - &.jumping { + &[data-loading], + &[data-jumping] { opacity: 0; pointer-events: none; } - .dim { + [data-dim] { opacity: 0.5; } - .scroll { + [data-chain-scroll] { height: 100%; padding: 0; overflow-y: auto; @@ -36,13 +36,13 @@ scrollbar-width: none; } - .blocks { + [data-chain-blocks] { display: flex; flex-direction: column-reverse; min-height: 100%; } - .cube { + [data-cube] { --cube-fall-off: pow( clamp( 0, @@ -61,12 +61,12 @@ margin-bottom: var(--block-gap); } - .face-text .height { + .face-text [data-cube-height] { font-size: var(--font-size-sm); font-weight: normal; } - .face-text .fees { + .face-text [data-cube-fees] { display: flex; flex-direction: column; align-items: center; @@ -74,7 +74,7 @@ height: 100%; } - .face-text .pool { + .face-text [data-cube-pool] { display: flex; align-items: center; justify-content: center; @@ -97,7 +97,7 @@ } } - .edge { + [data-edge] { position: absolute; top: calc(var(--main-padding) + 2.5rem); left: calc(var(--cube-size) * var(--iso-scale)); @@ -134,7 +134,7 @@ height: 14rem; min-height: 14rem; - .scroll { + [data-chain-scroll] { display: grid; align-items: center; grid-column: 1; @@ -146,21 +146,21 @@ touch-action: pan-x; } - .blocks { + [data-chain-blocks] { flex-direction: row-reverse; align-items: center; width: max-content; min-height: 0; } - .cube { + [data-cube] { & + & { margin-right: var(--block-gap); margin-bottom: 0; } } - .edge { + [data-edge] { top: 50%; left: 0.75rem; translate: 0 -50%; diff --git a/website_next/explore/chain/transition.js b/website_next/explore/chain/transition.js new file mode 100644 index 000000000..7b0c5cc17 --- /dev/null +++ b/website_next/explore/chain/transition.js @@ -0,0 +1,32 @@ +/** + * @param {Element} element + * @param {string} property + */ +export function transitionMs(element, property) { + const style = getComputedStyle(element); + const properties = style.transitionProperty.split(",").map((part) => { + return part.trim(); + }); + const durations = parseCssTimes(style.transitionDuration); + const delays = parseCssTimes(style.transitionDelay); + const index = properties.findIndex((part) => { + return part === property || part === "all"; + }); + + if (index < 0) return 0; + + const duration = durations[index] ?? durations.at(-1) ?? 0; + const delay = delays[index] ?? delays.at(-1) ?? 0; + + return duration + delay; +} + +/** @param {string} value */ +function parseCssTimes(value) { + return value.split(",").map((part) => { + const time = part.trim(); + const amount = Number.parseFloat(time); + + return time.endsWith("ms") ? amount : amount * 1_000; + }); +} diff --git a/website_next/utils/fee-rate.js b/website_next/utils/fee-rate.js new file mode 100644 index 000000000..582c784e6 --- /dev/null +++ b/website_next/utils/fee-rate.js @@ -0,0 +1,9 @@ +/** @param {number} rate */ +export function formatFeeRate(rate) { + if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`; + if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`; + if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`; + if (rate >= 100) return Math.round(rate).toLocaleString(); + if (rate >= 10) return rate.toFixed(1); + return rate.toFixed(2); +} diff --git a/website_next/wallets/amount/index.js b/website_next/wallets/amount/index.js index 5f644ef41..ad110b8fd 100644 --- a/website_next/wallets/amount/index.js +++ b/website_next/wallets/amount/index.js @@ -22,6 +22,8 @@ const amounts = /** @type {BtcAmountRecord[]} */ ([]); * @param {BtcAmount} amount */ function renderBtcAmount(element, amount) { + element.dataset.btcAmount = ""; + if (redaction.isHidden()) { element.textContent = FIXED_PRIVATE_TEXT; return; @@ -43,7 +45,6 @@ export function createBtcAmount(tag, sats, options = {}) { signed: options.signed === true, }; - element.classList.add("amount"); amounts.push({ element, amount }); renderBtcAmount(element, amount);