website: snap

This commit is contained in:
nym21
2026-05-16 22:12:44 +02:00
parent 421e5286ce
commit e5819769e8
11 changed files with 339 additions and 267 deletions

View File

@@ -139,7 +139,6 @@
<aside id="aside"> <aside id="aside">
<div id="explorer" hidden></div> <div id="explorer" hidden></div>
<div id="chart" hidden></div> <div id="chart" hidden></div>
<div id="table" hidden></div>
</aside> </aside>
<footer> <footer>
<fieldset id="frame-selectors"> <fieldset id="frame-selectors">

View File

@@ -114,7 +114,7 @@ export function init(selected) {
function startPolling() { function startPolling() {
stopPolling(); stopPolling();
poll(); poll();
pollInterval = setInterval(poll, 5_000); pollInterval = setInterval(poll, 1_000);
} }
function stopPolling() { function stopPolling() {

View File

@@ -388,7 +388,7 @@ export function initOptions() {
summary.append(node.name); summary.append(node.name);
const count = window.document.createElement("small"); const count = window.document.createElement("small");
count.textContent = `(${node.count.toLocaleString("en-us")})`; count.textContent = `[${node.count.toLocaleString("en-us")}]`;
summary.append(count); summary.append(count);
let built = false; let built = false;

View File

@@ -29,7 +29,7 @@ export function initPrice(brk) {
} }
poll(); poll();
setInterval(poll, 5_000); setInterval(poll, 1_000);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
!document.hidden && poll(); !document.hidden && poll();
}); });

View File

@@ -19,16 +19,33 @@ export function onChange(callback) {
return () => callbacks.delete(callback); return () => callbacks.delete(callback);
} }
const themeButton = /** @type {HTMLButtonElement | null} */ (
document.getElementById("theme-button")
);
let running = false;
/** @param {boolean} value */ /** @param {boolean} value */
function setDark(value) { function setDark(value) {
if (dark === value) return; if (running || dark === value) return;
dark = value; dark = value;
running = true;
if (themeButton) themeButton.disabled = true;
const swap = () => { const swap = () => {
apply(value); apply(value);
callbacks.forEach((cb) => cb()); callbacks.forEach((cb) => cb());
}; };
if (document.startViewTransition) document.startViewTransition(swap); document.documentElement.classList.add("no-transitions");
else swap(); const restore = () => {
document.documentElement.classList.remove("no-transitions");
running = false;
if (themeButton) themeButton.disabled = false;
};
if (document.startViewTransition) {
document.startViewTransition(swap).finished.finally(restore);
} else {
swap();
requestAnimationFrame(restore);
}
} }
/** @param {boolean} isDark */ /** @param {boolean} isDark */
@@ -53,4 +70,4 @@ function invert() {
} }
} }
document.getElementById("theme-button")?.addEventListener("click", invert); themeButton?.addEventListener("click", invert);

View File

@@ -1,6 +1,5 @@
/** /**
* @param {number} [fill] * @param {number} [fill]
* @returns {{ el: HTMLAnchorElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
*/ */
export function createCubeAnchor(fill = 1) { export function createCubeAnchor(fill = 1) {
const el = document.createElement("a"); const el = document.createElement("a");
@@ -9,7 +8,6 @@ export function createCubeAnchor(fill = 1) {
/** /**
* @param {number} [fill] * @param {number} [fill]
* @returns {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
*/ */
export function createCubeDiv(fill = 1) { export function createCubeDiv(fill = 1) {
const el = document.createElement("div"); const el = document.createElement("div");

View File

@@ -1,41 +1,36 @@
@property --pulse-mix {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
:root { :root {
--cube-size: 4.5rem; --cube-size: 4.5rem;
--iso-scale: calc(sqrt(3) / 2); --iso-scale: calc(sqrt(3) / 2);
--cube-empty-alpha: 0.4; --cube-empty-alpha: 0.4;
--cube-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1);
--face-step: 0.033; --face-step: 0.033;
} }
@keyframes cube-pulse {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.1); }
}
.cube { .cube {
--cube-width: calc(var(--cube-size) * 2 * var(--iso-scale)); --cube-width: calc(var(--cube-size) * 2 * var(--iso-scale));
--cube-height: calc(var(--cube-size) * 2); --cube-height: calc(var(--cube-size) * 2);
--face-base-light: var(--light-gray); --cube-face-base: var(--border-color);
--face-base-dark: var(--dark-gray);
--face-bottom-base: var(--border-color);
/* Light mode darkens from base, dark mode lightens. The asymmetry
can't be encoded in a single base, so each face picks its branch
from the matching flat base. State classes swap the bases only,
the per-face pattern stays. */
--face-top: light-dark( --face-top: light-dark(
var(--face-base-light), var(--cube-face-base),
oklch(from var(--face-base-dark) calc(l + var(--face-step) * 2) c h) oklch(from var(--cube-face-base) calc(l + var(--face-step) * 2) c h)
); );
--face-right: light-dark( --face-right: light-dark(
oklch(from var(--face-base-light) calc(l - var(--face-step) * 2) c h), oklch(from var(--cube-face-base) calc(l - var(--face-step) * 2) c h),
var(--face-base-dark) var(--cube-face-base)
); );
--face-left: light-dark( --face-left: light-dark(
oklch(from var(--face-base-light) calc(l - var(--face-step)) c h), oklch(from var(--cube-face-base) calc(l - var(--face-step)) c h),
oklch(from var(--face-base-dark) calc(l + var(--face-step)) c h) oklch(from var(--cube-face-base) calc(l + var(--face-step)) c h)
);
--face-bottom: oklch(
from var(--cube-face-base) calc(l - var(--face-step) * 3) c h
); );
--face-bottom: oklch(from var(--face-bottom-base) calc(l - var(--face-step) * 3) c h);
--is-full: round(down, var(--fill), 1); --is-full: round(down, var(--fill), 1);
--is-empty: round(down, calc(1 - var(--fill)), 1); --is-empty: round(down, calc(1 - var(--fill)), 1);
@@ -49,38 +44,43 @@
overflow: visible; overflow: visible;
text-decoration: none; text-decoration: none;
color: var(--color); color: var(--color);
transition: color var(--cube-ease);
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
&:hover { /* Hover/active styling is gated on the anchor tag: only <a> cubes
(confirmed blocks) react to pointer state. Plain <div> projected
previews stay visually inert. --face-color-base is resolved lazily,
so the orange override below feeds the face declarations too. */
&:is(a):hover,
&:is(a):active,
&.selected {
color: var(--background-color); color: var(--background-color);
--face-base-light: var(--dark-gray); --face-color-base: var(--inv-border-color);
--face-base-dark: var(--light-gray);
--face-bottom-base: var(--inv-border-color);
}
/* Color states: override --face-* directly with a fixed
darken-from-base derivation so the cube renders identically in
light and dark mode (no theme-flip on the colored faces). */
&:active,
&.selected { color: var(--black); --face-color-base: var(--orange); }
&.next { --face-color-base: var(--cyan); }
&.projected{ --face-color-base: var(--off-color); }
&:active,
&.selected,
&.next,
&.projected {
--face-top: var(--face-color-base); --face-top: var(--face-color-base);
--face-right: oklch(from var(--face-color-base) calc(l - var(--face-step) * 2) c h); --face-right: oklch(
--face-left: oklch(from var(--face-color-base) calc(l - var(--face-step)) c h); from var(--face-color-base) calc(l - var(--face-step) * 2) c h
--face-bottom: oklch(from var(--face-color-base) calc(l - var(--face-step) * 3) c h); );
--face-left: oklch(
from var(--face-color-base) calc(l - var(--face-step)) c h
);
--face-bottom: oklch(
from var(--face-color-base) calc(l - var(--face-step) * 3) c h
);
}
&:is(a):active,
&.selected {
color: var(--black);
--face-color-base: var(--orange);
} }
&.next,
&.projected { &.projected {
animation: cube-pulse 2.5s ease-in-out infinite; animation: cube-pulse 4s ease-in-out infinite;
--cube-face-base: color-mix(
in oklch,
var(--border-color),
var(--background-color) calc(var(--pulse-mix) * 100%)
);
} }
/* visibility (not color:transparent) so child <img> hides too */ /* visibility (not color:transparent) so child <img> hides too */
@@ -94,10 +94,11 @@
box-sizing: border-box; box-sizing: border-box;
width: var(--cube-size); width: var(--cube-size);
height: var(--cube-size); height: var(--cube-size);
transform: transform: translateY(50%) var(--face-orient)
translateY(50%) translate(
var(--face-orient) calc(var(--cube-size) * var(--face-x)),
translate(calc(var(--cube-size) * var(--face-x)), calc(var(--cube-size) * var(--face-y))) calc(var(--cube-size) * var(--face-y))
)
scale(var(--face-scale-x, 1), var(--face-scale-y)); scale(var(--face-scale-x, 1), var(--face-scale-y));
pointer-events: auto; pointer-events: auto;
} }
@@ -107,7 +108,6 @@
.liquid, .liquid,
.glass { .glass {
will-change: background-color; will-change: background-color;
transition: background-color var(--cube-ease);
} }
.liquid { .liquid {
background: var(--face-color); background: var(--face-color);
@@ -120,9 +120,6 @@
--face-scale-y: calc(var(--iso-scale) * (1 - var(--fill))); --face-scale-y: calc(var(--iso-scale) * (1 - var(--fill)));
--face-stack-shift: 0; --face-stack-shift: 0;
} }
.glass.top {
opacity: calc(1 - var(--is-full));
}
.face-text { .face-text {
--face-scale-y: var(--iso-scale); --face-scale-y: var(--iso-scale);
@@ -132,24 +129,26 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 450; font-weight: 450;
}
.face-text.top,
.face-text.right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
}
.face-text.top { &.top {
justify-content: center; justify-content: center;
text-transform: uppercase; text-transform: uppercase;
} }
.face-text.right { &.right {
justify-content: space-between; justify-content: space-between;
} }
.face-text p { &.left {
justify-content: center;
}
p {
margin: 0; margin: 0;
} }
}
.top, .top,
.bottom { .bottom {
@@ -164,7 +163,6 @@
.rear-right { .rear-right {
--face-orient: rotate(30deg) skewX(30deg); --face-orient: rotate(30deg) skewX(30deg);
} }
.top, .top,
.rear-right { .rear-right {
--face-y: calc(var(--face-stack-shift) - var(--iso-scale)); --face-y: calc(var(--face-stack-shift) - var(--iso-scale));
@@ -179,13 +177,42 @@
.bottom { .bottom {
--face-y: 0; --face-y: 0;
} }
.top {
.top { --face-color: var(--face-top); --face-x: 0; } --face-color: var(--face-top);
.bottom { --face-color: var(--face-bottom); --face-x: 1; } --face-x: 0;
.right { --face-color: var(--face-right); --face-x: 1; } }
.left { --face-color: var(--face-left); --face-x: 0; } .bottom {
.rear-right { --face-color: var(--face-left); --face-x: 1; } --face-color: var(--face-bottom);
.rear-left { --face-color: var(--face-top); --face-x: 1; --face-scale-x: -1; } --face-x: 1;
/* Top liquid face slides as fill drops, animating the surface level. */ }
.liquid.top { --face-x: calc(1 - var(--fill)); } .right {
--face-color: var(--face-right);
--face-x: 1;
}
.left {
--face-color: var(--face-left);
--face-x: 0;
}
.rear-right {
--face-color: var(--face-left);
--face-x: 1;
}
.rear-left {
--face-color: var(--face-top);
--face-x: 1;
--face-scale-x: -1;
}
.liquid.top {
--face-x: calc(1 - var(--fill));
}
}
@keyframes cube-pulse {
0%,
100% {
--pulse-mix: 0.5;
}
50% {
--pulse-mix: 1;
}
} }

View File

@@ -24,10 +24,10 @@ const MONTHS = [
"Dec", "Dec",
]; ];
/** @type {HTMLElement} */ let explorerEl;
/** @type {HTMLDivElement} */ let chainEl; /** @type {HTMLDivElement} */ let chainEl;
/** @type {HTMLDivElement} */ let scrollEl; /** @type {HTMLDivElement} */ let scrollEl;
/** @type {HTMLDivElement} */ let confirmedEl; /** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLDivElement} */ let projectedEl;
/** @type {HTMLAnchorElement | null} */ let selectedCube = null; /** @type {HTMLAnchorElement | null} */ let selectedCube = null;
/** @type {IntersectionObserver} */ let olderEdgeObserver; /** @type {IntersectionObserver} */ let olderEdgeObserver;
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {}; /** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
@@ -37,7 +37,7 @@ const MONTHS = [
/** @type {Map<BlockHash, BlockInfoV1>} */ /** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map(); const blocksByHash = new Map();
/** @type {Array<{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }>} */ /** @type {ReturnType<typeof createProjectedCube>[]} */
const projectedCubes = []; const projectedCubes = [];
let newestHeight = -1; let newestHeight = -1;
@@ -61,6 +61,7 @@ export function initChain(parent, callbacks) {
onCubeClick = callbacks.onCubeClick; onCubeClick = callbacks.onCubeClick;
onTip = callbacks.onTip; onTip = callbacks.onTip;
onGenesis = callbacks.onGenesis; onGenesis = callbacks.onGenesis;
explorerEl = parent;
chainEl = document.createElement("div"); chainEl = document.createElement("div");
chainEl.id = "chain"; chainEl.id = "chain";
@@ -72,17 +73,12 @@ export function initChain(parent, callbacks) {
); );
scrollEl = document.createElement("div"); scrollEl = document.createElement("div");
scrollEl.classList.add("chain-scroll"); scrollEl.classList.add("scroll");
chainEl.append(scrollEl); chainEl.append(scrollEl);
projectedEl = document.createElement("div"); blocksEl = document.createElement("div");
projectedEl.classList.add("projected"); blocksEl.classList.add("blocks");
projectedEl.hidden = true; scrollEl.append(blocksEl);
scrollEl.append(projectedEl);
confirmedEl = document.createElement("div");
confirmedEl.classList.add("confirmed");
scrollEl.append(confirmedEl);
olderEdgeObserver = new IntersectionObserver( olderEdgeObserver = new IntersectionObserver(
(entries) => { (entries) => {
@@ -140,7 +136,10 @@ export async function goToCube(hashOrHeight, { silent } = {}) {
selectCube(cube, { scroll: "smooth", silent }); selectCube(cube, { scroll: "smooth", silent });
return; return;
} }
for (const cube of confirmedEl.children) cube.classList.add("skeleton"); for (const cube of blocksEl.children) {
if (!cube.classList.contains("projected")) cube.classList.add("skeleton");
}
explorerEl.classList.add("loading");
let startHash; let startHash;
try { try {
const height = await resolveHeight(hashOrHeight); const height = await resolveHeight(hashOrHeight);
@@ -149,6 +148,7 @@ export async function goToCube(hashOrHeight, { silent } = {}) {
try { try {
startHash = await loadInitial(null); startHash = await loadInitial(null);
} catch (_) { } catch (_) {
explorerEl.classList.remove("loading");
return; return;
} }
} }
@@ -156,14 +156,12 @@ export async function goToCube(hashOrHeight, { silent } = {}) {
scroll: "instant", scroll: "instant",
silent, silent,
}); });
explorerEl.classList.remove("loading");
} }
export async function poll() { export async function poll() {
if (!reachedTip) return; if (!reachedTip) return;
brk pollProjected();
.getMempoolBlocks()
.then(renderProjected)
.catch((e) => console.error("mempool poll:", e));
try { try {
const blocks = await brk.getBlocksV1(); const blocks = await brk.getBlocksV1();
appendNewerBlocks(blocks); appendNewerBlocks(blocks);
@@ -172,16 +170,32 @@ export async function poll() {
} }
} }
function pollProjected() {
return brk
.getMempoolBlocks()
.then(renderProjected)
.catch((e) => console.error("mempool poll:", e));
}
/** @param {BlockHash | Height | null} [hashOrHeight] */ /** @param {BlockHash | Height | null} [hashOrHeight] */
function findCube(hashOrHeight) { function findCube(hashOrHeight) {
if (hashOrHeight == null) { if (hashOrHeight == null) {
return reachedTip && newestHeight >= 0 return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null;
? /** @type {HTMLAnchorElement | null} */ (confirmedEl.lastElementChild)
: null;
} }
const attr = typeof hashOrHeight === "number" ? "height" : "hash"; const attr = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLAnchorElement | null} */ ( return /** @type {HTMLAnchorElement | null} */ (
confirmedEl.querySelector(`[data-${attr}="${hashOrHeight}"]`) blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
);
}
function firstProjectedCube() {
return projectedCubes[0]?.el ?? null;
}
function newestConfirmedCube() {
const firstProj = firstProjectedCube();
return /** @type {HTMLAnchorElement | null} */ (
firstProj ? firstProj.previousElementSibling : blocksEl.lastElementChild
); );
} }
@@ -193,20 +207,21 @@ function clear() {
loadingNewer = false; loadingNewer = false;
reachedTip = false; reachedTip = false;
selectedCube = null; selectedCube = null;
confirmedEl.innerHTML = ""; blocksEl.innerHTML = "";
projectedCubes.length = 0;
olderEdgeObserver.disconnect(); olderEdgeObserver.disconnect();
} }
function observeOldestEdge() { function observeOldestEdge() {
olderEdgeObserver.disconnect(); olderEdgeObserver.disconnect();
const oldest = confirmedEl.firstElementChild; const oldest = blocksEl.firstElementChild;
if (oldest) olderEdgeObserver.observe(oldest); if (oldest) olderEdgeObserver.observe(oldest);
} }
/** @param {BlockInfoV1[]} blocks */ /** @param {BlockInfoV1[]} blocks */
function appendNewerBlocks(blocks) { function appendNewerBlocks(blocks) {
if (!blocks.length) return false; if (!blocks.length) return false;
const anchor = confirmedEl.lastElementChild; const anchor = newestConfirmedCube();
const anchorRect = anchor?.getBoundingClientRect(); const anchorRect = anchor?.getBoundingClientRect();
for (let i = blocks.length - 1; i >= 0; i--) { for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i]; const b = blocks[i];
@@ -215,7 +230,7 @@ function appendNewerBlocks(blocks) {
} }
newestHeight = Math.max(newestHeight, blocks[0].height); newestHeight = Math.max(newestHeight, blocks[0].height);
newestTimestamp = blocks[0].timestamp; newestTimestamp = blocks[0].timestamp;
refreshProjectedIntervals(); refreshProjected();
if (anchor && anchorRect) { if (anchor && anchorRect) {
const r = anchor.getBoundingClientRect(); const r = anchor.getBoundingClientRect();
scrollEl.scrollTop += r.top - anchorRect.top; scrollEl.scrollTop += r.top - anchorRect.top;
@@ -240,6 +255,9 @@ async function loadInitial(height) {
observeOldestEdge(); observeOldestEdge();
if (!reachedTip) await loadNewer(); if (!reachedTip) await loadNewer();
// Await the projected cubes so the layout is complete before the caller
// scrolls to the tip; otherwise they load late and push the tip out of view.
else await pollProjected();
return blocks[0].id; return blocks[0].id;
} }
@@ -278,8 +296,10 @@ async function loadNewer() {
try { try {
const prevNewest = newestHeight; const prevNewest = newestHeight;
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD); const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
reachedTip = true; reachedTip = true;
await pollProjected();
}
} catch (e) { } catch (e) {
console.error("explorer loadNewer:", e); console.error("explorer loadNewer:", e);
} }
@@ -354,99 +374,111 @@ function setConfirmedInterval(cube) {
/** @param {HTMLAnchorElement} cube */ /** @param {HTMLAnchorElement} cube */
function prependConfirmed(cube) { function prependConfirmed(cube) {
const next = /** @type {HTMLElement | null} */ ( const oldFirst = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild);
confirmedEl.firstElementChild blocksEl.insertBefore(cube, oldFirst);
); if (oldFirst) setConfirmedInterval(oldFirst);
confirmedEl.prepend(cube);
if (next) setConfirmedInterval(next);
} }
/** @param {HTMLAnchorElement} cube */ /** @param {HTMLAnchorElement} cube */
function appendConfirmed(cube) { function appendConfirmed(cube) {
confirmedEl.append(cube); blocksEl.insertBefore(cube, firstProjectedCube());
setConfirmedInterval(cube); setConfirmedInterval(cube);
} }
/** @param {MempoolBlock[]} blocks */ /** @param {MempoolBlock[]} blocks */
function renderProjected(blocks) { function renderProjected(blocks) {
const want = Math.min(blocks.length, PROJECTED_LIMIT); const want = Math.min(blocks.length, PROJECTED_LIMIT);
projectedEl.hidden = want === 0;
while (projectedCubes.length > want) { while (projectedCubes.length > want) {
const last = projectedCubes.pop(); const last = projectedCubes.pop();
if (last) last.el.remove(); if (last) last.el.remove();
} }
while (projectedCubes.length < want) { while (projectedCubes.length < want) {
const cube = createProjectedCube(projectedCubes.length); const cube = createProjectedCube();
projectedCubes.push(cube); projectedCubes.push(cube);
projectedEl.append(cube.el); blocksEl.append(cube.el);
} }
for (let i = 0; i < want; i++) for (let i = 0; i < want; i++) updateProjectedCube(projectedCubes[i], blocks[i]);
updateProjectedCube(projectedCubes[i], blocks[i], i); refreshProjected();
refreshProjectedIntervals();
} }
/** @param {number} index */ function createProjectedCube() {
function createProjectedCube(index) { const cube = createCubeDiv();
const { el, topFace, rightFace, leftFace } = createCubeDiv(0); cube.el.classList.add("projected");
el.classList.add("projected");
if (index === 0) el.classList.add("next"); const date = document.createTextNode("");
return { el, topFace, rightFace, leftFace }; const hh = document.createTextNode("");
const mm = document.createTextNode("");
const dateP = document.createElement("p");
dateP.append(date);
const timeP = document.createElement("p");
timeP.append(hh, span(":", "dim"), mm);
cube.topFace.append(dateP, timeP);
const txs = document.createTextNode("");
const txsUnit = document.createTextNode("");
const txsP = document.createElement("p");
txsP.append(txs);
const txsUnitP = document.createElement("p");
txsUnitP.classList.add("dim");
txsUnitP.append(txsUnit);
cube.rightFace.append(txsP, txsUnitP);
const median = document.createTextNode("");
const rangeLo = document.createTextNode("");
const rangeHi = document.createTextNode("");
const medianP = document.createElement("p");
medianP.append(span("~", "dim"), median);
const rangeP = document.createElement("p");
rangeP.append(rangeLo, span("-", "dim"), rangeHi);
const unitP = document.createElement("p");
unitP.classList.add("dim");
unitP.textContent = "sat/vB";
cube.leftFace.append(medianP, rangeP, unitP);
return {
...cube,
parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi },
};
} }
/** /**
* @param {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }} cube * @param {ReturnType<typeof createProjectedCube>} cube
* @param {MempoolBlock} block * @param {MempoolBlock} block
* @param {number} index
*/ */
function updateProjectedCube(cube, block, index) { function updateProjectedCube(cube, block) {
const fill = Math.min(1, block.blockVSize / 1_000_000); cube.el.style.setProperty(
cube.el.style.setProperty("--fill", String(fill)); "--fill",
String(Math.min(1, block.blockVSize / 1_000_000)),
cube.topFace.textContent = "";
const label = document.createElement("p");
label.textContent = index === 0 ? "next" : `+${index}`;
cube.topFace.append(label);
cube.rightFace.textContent = "";
const txs = document.createElement("p");
txs.textContent = block.nTx.toLocaleString();
const txsUnit = document.createElement("p");
txsUnit.classList.add("dim");
txsUnit.textContent = block.nTx === 1 ? "tx" : "txs";
cube.rightFace.append(txs, txsUnit);
cube.leftFace.textContent = "";
const median = document.createElement("p");
median.append(span("~", "dim"), formatFeeRate(block.medianFee));
const range = document.createElement("p");
range.append(
formatFeeRate(block.feeRange[0]),
span("-", "dim"),
formatFeeRate(block.feeRange[6]),
); );
const unit = document.createElement("p"); const p = cube.parts;
unit.classList.add("dim"); p.txs.nodeValue = block.nTx.toLocaleString();
unit.textContent = "sat/vB"; p.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs";
cube.leftFace.append(median, range, unit); p.median.nodeValue = formatFeeRate(block.medianFee);
p.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]);
p.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]);
} }
function refreshProjectedIntervals() { function refreshProjected() {
if (!projectedCubes.length || !newestTimestamp) return; if (!projectedCubes.length || !newestTimestamp) return;
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - newestTimestamp); const now = Math.floor(Date.now() / 1000);
const elapsed = Math.max(0, now - newestTimestamp);
for (let i = 0; i < projectedCubes.length; i++) { for (let i = 0; i < projectedCubes.length; i++) {
const interval = TARGET_BLOCK_SECONDS * i + elapsed; const cube = projectedCubes[i];
projectedCubes[i].el.style.setProperty( const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
"--block-interval", cube.el.style.setProperty("--block-interval", String(interval));
String(interval), const ts = now + i * TARGET_BLOCK_SECONDS;
); const [hh, mm] = formatHHMM(ts);
cube.parts.date.nodeValue = formatShortDate(ts);
cube.parts.hh.nodeValue = hh;
cube.parts.mm.nodeValue = mm;
} }
} }
/** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */ /** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */
function createEdgeLink(label, href, title, handler) { function createEdgeLink(label, href, title, handler) {
const a = document.createElement("a"); const a = document.createElement("a");
a.classList.add("chain-edge", label); a.classList.add("edge", label);
a.href = href; a.href = href;
a.title = title; a.title = title;
a.textContent = label; a.textContent = label;

View File

@@ -14,13 +14,13 @@
height: 100%; height: 100%;
} }
.chain-scroll { .scroll {
padding: 0 var(--main-padding); padding: 0 var(--main-padding);
scrollbar-width: none;
@container aside (max-width: 767px) { @container aside (max-width: 767px) {
padding-bottom: 1rem; padding-bottom: 1rem;
overflow-x: auto; overflow-x: auto;
width: max-content;
} }
@container aside (min-width: 768px) { @container aside (min-width: 768px) {
@@ -29,49 +29,14 @@
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
} }
}
.chain-edge { .blocks {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-xs);
text-transform: uppercase;
text-decoration: none;
letter-spacing: 0.1em;
font-weight: 500;
@container aside (max-width: 767px) {
height: 100%;
width: var(--main-padding);
writing-mode: vertical-lr;
text-orientation: upright;
}
@container aside (min-width: 768px) {
width: 100%;
height: var(--main-padding);
padding-left: calc(var(--main-padding) / 2);
}
}
.tip {
top: 0;
left: 0;
}
.gen {
bottom: 0;
right: 0;
}
.projected,
.confirmed {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
@container aside (max-width: 767px) { @container aside (max-width: 767px) {
flex-direction: row-reverse; flex-direction: row-reverse;
} width: max-content;
} }
.cube { .cube {
@@ -85,34 +50,16 @@
0.7 0.7
); );
--block-gap: calc( --block-gap: calc(
var(--min-gap) + var(--cube-fall-off) * (var(--max-gap) - var(--min-gap)) var(--min-gap) + var(--cube-fall-off) *
(var(--max-gap) - var(--min-gap))
); );
& + & { & + & {
margin-bottom: var(--block-gap); margin-bottom: var(--block-gap);
&::before {
content: "";
position: absolute;
top: 100%;
left: 50%;
width: 1px;
height: var(--block-gap);
background: var(--border-color);
z-index: -1;
}
@container aside (max-width: 767px) { @container aside (max-width: 767px) {
margin-bottom: 0; margin-bottom: 0;
margin-right: var(--block-gap); margin-right: var(--block-gap);
&::before {
top: 50%;
left: auto;
right: calc(-1 * var(--block-gap));
width: var(--block-gap);
height: 1px;
}
} }
} }
@@ -149,3 +96,39 @@
} }
} }
} }
}
.edge {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-xs);
text-transform: uppercase;
text-decoration: none;
letter-spacing: 0.1em;
font-weight: 500;
@container aside (max-width: 767px) {
height: 100%;
width: var(--main-padding);
writing-mode: vertical-lr;
text-orientation: upright;
}
@container aside (min-width: 768px) {
width: 100%;
height: var(--main-padding);
padding-left: calc(var(--main-padding) / 2);
}
&.tip {
top: 0;
left: 0;
}
&.gen {
bottom: 0;
right: 0;
}
}
}

View File

@@ -102,6 +102,7 @@ button {
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
text-transform: inherit; text-transform: inherit;
user-select: none;
} }
h1 { h1 {

View File

@@ -3,6 +3,13 @@
height: 100%; height: 100%;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
transition: opacity 200ms ease;
/* Held invisible while the chain rebuilds and scrolls to the target,
then faded in so the layout settling isn't visible. */
&.loading {
opacity: 0;
}
.dim { .dim {
opacity: 0.5; opacity: 0.5;
@@ -356,8 +363,16 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
line-height: var(--line-height-sm); line-height: var(--line-height-sm);
/* #explorer supplies only vertical padding on mobile. */
@container aside (max-width: 767px) {
padding: 0 var(--main-padding);
}
/* Full padding, halved on the side facing the chain so its halved
right padding and this halved left padding form one gutter. */
@container aside (min-width: 768px) { @container aside (min-width: 768px) {
overflow-y: auto; overflow-y: auto;
padding: var(--main-padding);
padding-left: calc(var(--main-padding) / 2); padding-left: calc(var(--main-padding) / 2);
} }