mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-26 02:08:10 -07:00
website_next: part 4
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { createEnteringConfirmedCube, setConfirmedInterval } from "./block-cube.js";
|
||||
import { scrollToElement } from "./scroll.js";
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block */
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {() => Element | null} args.firstProjectedElement
|
||||
* @param {(block: Block) => void} args.onSelect
|
||||
* @param {() => void} args.onScrollSelect
|
||||
*/
|
||||
export function createConfirmedBlocks({
|
||||
blocksElement,
|
||||
firstProjectedElement,
|
||||
onSelect,
|
||||
onScrollSelect,
|
||||
}) {
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let selectedCube = null;
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let tipCube = null;
|
||||
/** @type {Map<string, Block>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
function clear() {
|
||||
selectedCube = null;
|
||||
tipCube = null;
|
||||
blocksByHash.clear();
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function cache(block) {
|
||||
blocksByHash.set(block.id, block);
|
||||
}
|
||||
|
||||
/** @param {string} hash */
|
||||
function get(hash) {
|
||||
return blocksByHash.get(hash);
|
||||
}
|
||||
|
||||
/** @param {string | number} hashOrHeight */
|
||||
function find(hashOrHeight) {
|
||||
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
function newest() {
|
||||
const firstProjected = firstProjectedElement();
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
firstProjected
|
||||
? firstProjected.previousElementSibling
|
||||
: blocksElement.lastElementChild
|
||||
);
|
||||
}
|
||||
|
||||
function markTip() {
|
||||
tipCube?.removeAttribute("data-tip");
|
||||
tipCube = newest();
|
||||
tipCube?.setAttribute("data-tip", "");
|
||||
}
|
||||
|
||||
function deselect() {
|
||||
if (selectedCube) delete selectedCube.dataset.selected;
|
||||
selectedCube = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} cube
|
||||
* @param {{ scroll?: "smooth" | "instant" }} [options]
|
||||
*/
|
||||
function select(cube, { scroll } = {}) {
|
||||
if (cube !== selectedCube) {
|
||||
deselect();
|
||||
selectedCube = cube;
|
||||
cube.dataset.selected = "";
|
||||
}
|
||||
|
||||
const hash = cube.dataset.hash;
|
||||
const block = hash ? get(hash) : undefined;
|
||||
if (block) onSelect(block);
|
||||
|
||||
if (scroll) {
|
||||
scrollToElement(cube, scroll);
|
||||
onScrollSelect();
|
||||
}
|
||||
}
|
||||
|
||||
function markSkeletons() {
|
||||
for (const cube of blocksElement.children) {
|
||||
if (!cube.hasAttribute("data-projected")) {
|
||||
cube.setAttribute("data-skeleton", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function create(block) {
|
||||
cache(block);
|
||||
|
||||
return createEnteringConfirmedCube(block, select);
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function prepend(block) {
|
||||
const cube = create(block);
|
||||
const oldFirst = /** @type {HTMLElement | null} */ (
|
||||
blocksElement.firstElementChild
|
||||
);
|
||||
|
||||
blocksElement.insertBefore(cube, oldFirst);
|
||||
if (oldFirst) setConfirmedInterval(oldFirst);
|
||||
|
||||
return cube;
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function append(block) {
|
||||
const cube = create(block);
|
||||
|
||||
blocksElement.insertBefore(cube, firstProjectedElement());
|
||||
setConfirmedInterval(cube);
|
||||
|
||||
return cube;
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
append,
|
||||
cache,
|
||||
clear,
|
||||
create,
|
||||
find,
|
||||
get,
|
||||
markSkeletons,
|
||||
markTip,
|
||||
newest,
|
||||
select,
|
||||
tipCube: () => tipCube,
|
||||
prepend,
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,24 @@
|
||||
import { brk } from "../../utils/client.js";
|
||||
import {
|
||||
createEnteringConfirmedCube,
|
||||
createPlaceholderCube,
|
||||
createProjectedCube,
|
||||
setConfirmedInterval,
|
||||
updateProjectedCube,
|
||||
updateProjectedTime,
|
||||
} from "./block-cube.js";
|
||||
import { createConfirmedBlocks } from "./confirmed.js";
|
||||
import { createEdgeButton } from "./edge.js";
|
||||
import {
|
||||
distanceFromViewport,
|
||||
findVisibleConfirmedHeight,
|
||||
isHorizontalLayout,
|
||||
olderRemaining,
|
||||
olderRunway,
|
||||
olderWheelDelta,
|
||||
preserveScrollPosition,
|
||||
scrollToElement,
|
||||
} from "./scroll.js";
|
||||
import { createJumpController } from "./jump.js";
|
||||
import { createOlderBlocks } from "./older.js";
|
||||
import { createProjectedBlocks } from "./projected.js";
|
||||
import { createTipVisibility } from "./tip.js";
|
||||
|
||||
const BLOCK_BATCH_SIZE = 15;
|
||||
const EDGE_LOAD_DISTANCE = 50;
|
||||
const OLDER_RESERVE_VIEWPORTS = 6;
|
||||
const POLL_INTERVAL = 1_000;
|
||||
const PROJECTED_LIMIT = 8;
|
||||
const TARGET_BLOCK_SECONDS = 600;
|
||||
const TIP_BLOCK_THRESHOLD = 10;
|
||||
|
||||
/** @typedef {Awaited<ReturnType<typeof brk.getBlocksV1>>[number]} Block */
|
||||
/** @typedef {Awaited<ReturnType<typeof brk.getMempoolBlocks>>[number]} MempoolBlock */
|
||||
/** @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch */
|
||||
|
||||
/** @param {string | number | null | undefined} hashOrHeight */
|
||||
function normalizeTarget(hashOrHeight) {
|
||||
@@ -53,48 +41,64 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
jumpToTip();
|
||||
});
|
||||
const jump = createJumpController(element, () => {
|
||||
if (tipCube) selectCube(tipCube, { scroll: "instant" });
|
||||
const tipCube = confirmed.tipCube();
|
||||
if (tipCube) confirmed.select(tipCube, { scroll: "instant" });
|
||||
});
|
||||
|
||||
element.id = "chain";
|
||||
setTipVisible(false);
|
||||
scrollElement.dataset.chainScroll = "";
|
||||
blocksElement.dataset.chainBlocks = "";
|
||||
scrollElement.append(blocksElement);
|
||||
element.append(tipButton, scrollElement);
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let selectedCube = null;
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
let tipCube = null;
|
||||
|
||||
/** @type {Map<string, Block>} */
|
||||
const blocksByHash = new Map();
|
||||
|
||||
/** @type {ReturnType<typeof createProjectedCube>[]} */
|
||||
const projectedCubes = [];
|
||||
const projected = createProjectedBlocks({
|
||||
blocksElement,
|
||||
isLayoutFrozen: () => tipButton.hasAttribute("data-visible"),
|
||||
isElementVisible,
|
||||
});
|
||||
const confirmed = createConfirmedBlocks({
|
||||
blocksElement,
|
||||
firstProjectedElement: projected.firstElement,
|
||||
onSelect,
|
||||
onScrollSelect: () => tip.schedule(),
|
||||
});
|
||||
const tip = createTipVisibility({
|
||||
button: tipButton,
|
||||
reachedTip: () => reachedTip,
|
||||
newestHeight: () => newestHeight,
|
||||
tipCube: confirmed.tipCube,
|
||||
visibleConfirmedHeight: () =>
|
||||
findVisibleConfirmedHeight(scrollElement, blocksElement),
|
||||
hasVisibleProjected: () => projected.hasVisibleElement(),
|
||||
isElementVisible,
|
||||
});
|
||||
|
||||
let active = false;
|
||||
let newestHeight = -1;
|
||||
let oldestHeight = Infinity;
|
||||
let oldestReservedHeight = -1;
|
||||
let newestTimestamp = 0;
|
||||
let hydratingOlder = false;
|
||||
let loadingNewer = false;
|
||||
let polling = false;
|
||||
let reachedTip = false;
|
||||
let olderGeneration = 0;
|
||||
|
||||
/** @type {OlderBatch[]} */
|
||||
const olderBatches = [];
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollId;
|
||||
let tipSyncFrame = 0;
|
||||
|
||||
/** @type {AbortController} */
|
||||
let controller = new AbortController();
|
||||
|
||||
const older = createOlderBlocks({
|
||||
scrollElement,
|
||||
blocksElement,
|
||||
batchSize: BLOCK_BATCH_SIZE,
|
||||
isActive: () => active,
|
||||
isHorizontal,
|
||||
fetchBlocks: (startHeight) =>
|
||||
brk.getBlocksV1FromHeight(startHeight, { signal: controller.signal }),
|
||||
createCube: confirmed.create,
|
||||
isAborted: () => controller.signal.aborted,
|
||||
onError: (error) => logChainError("explore older:", error),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {unknown} error
|
||||
@@ -106,180 +110,57 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
/** @param {string | number | null | undefined} hashOrHeight */
|
||||
function findCube(hashOrHeight) {
|
||||
if (hashOrHeight == null) {
|
||||
return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null;
|
||||
return reachedTip && newestHeight >= 0 ? confirmed.newest() : null;
|
||||
}
|
||||
|
||||
const attribute = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
blocksElement.querySelector(`[data-${attribute}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
function firstProjectedCube() {
|
||||
return projectedCubes[0]?.element ?? null;
|
||||
}
|
||||
|
||||
function newestConfirmedCube() {
|
||||
const firstProjected = firstProjectedCube();
|
||||
|
||||
return /** @type {HTMLButtonElement | null} */ (
|
||||
firstProjected
|
||||
? firstProjected.previousElementSibling
|
||||
: blocksElement.lastElementChild
|
||||
);
|
||||
}
|
||||
|
||||
function deselectCube() {
|
||||
if (selectedCube) delete selectedCube.dataset.selected;
|
||||
selectedCube = null;
|
||||
}
|
||||
|
||||
function updateTipCube() {
|
||||
tipCube?.removeAttribute("data-tip");
|
||||
tipCube = newestConfirmedCube();
|
||||
tipCube?.setAttribute("data-tip", "");
|
||||
return confirmed.find(hashOrHeight);
|
||||
}
|
||||
|
||||
function jumpToTip() {
|
||||
if (tipCube) jump.jump();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} cube
|
||||
* @param {{ scroll?: "smooth" | "instant" }} [options]
|
||||
*/
|
||||
function selectCube(cube, { scroll } = {}) {
|
||||
if (cube !== selectedCube) {
|
||||
deselectCube();
|
||||
selectedCube = cube;
|
||||
cube.dataset.selected = "";
|
||||
}
|
||||
|
||||
const hash = cube.dataset.hash;
|
||||
const block = hash ? blocksByHash.get(hash) : undefined;
|
||||
if (block) onSelect(block);
|
||||
|
||||
if (scroll) {
|
||||
scrollToElement(cube, scroll);
|
||||
scheduleTipVisibilitySync();
|
||||
}
|
||||
}
|
||||
|
||||
function markConfirmedSkeletons() {
|
||||
for (const cube of blocksElement.children) {
|
||||
if (!cube.hasAttribute("data-projected")) {
|
||||
cube.setAttribute("data-skeleton", "");
|
||||
}
|
||||
}
|
||||
if (confirmed.tipCube()) jump.jump();
|
||||
}
|
||||
|
||||
function isHorizontal() {
|
||||
return isHorizontalLayout(blocksElement);
|
||||
}
|
||||
|
||||
/** @param {number} [delta] */
|
||||
function reserveOlderRunway(delta = 0) {
|
||||
if (!active || oldestReservedHeight <= 0) return;
|
||||
|
||||
const horizontal = isHorizontal();
|
||||
const runway =
|
||||
olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta;
|
||||
let remaining = olderRemaining(scrollElement, horizontal);
|
||||
|
||||
while (remaining < runway) {
|
||||
if (!reserveOlderBatch()) return;
|
||||
remaining = olderRemaining(scrollElement, horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
newestHeight = -1;
|
||||
oldestHeight = Infinity;
|
||||
oldestReservedHeight = -1;
|
||||
newestTimestamp = 0;
|
||||
hydratingOlder = false;
|
||||
loadingNewer = false;
|
||||
reachedTip = false;
|
||||
olderGeneration++;
|
||||
selectedCube = null;
|
||||
tipCube = null;
|
||||
blocksByHash.clear();
|
||||
confirmed.clear();
|
||||
blocksElement.textContent = "";
|
||||
projectedCubes.length = 0;
|
||||
olderBatches.length = 0;
|
||||
setTipVisible(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element | null} anchor
|
||||
* @param {number} count
|
||||
*/
|
||||
function prependOlderPlaceholders(anchor, count) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const placeholders = /** @type {HTMLElement[]} */ ([]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cube = createPlaceholderCube();
|
||||
|
||||
placeholders.push(cube);
|
||||
fragment.append(cube);
|
||||
}
|
||||
|
||||
blocksElement.insertBefore(fragment, anchor);
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
function reserveOlderBatch() {
|
||||
if (!active || oldestReservedHeight <= 0) return false;
|
||||
|
||||
const anchor = blocksElement.firstElementChild;
|
||||
const count = Math.min(BLOCK_BATCH_SIZE, oldestReservedHeight);
|
||||
const startHeight = oldestReservedHeight - 1;
|
||||
const placeholders = prependOlderPlaceholders(anchor, count);
|
||||
|
||||
if (!placeholders.length) return false;
|
||||
|
||||
oldestReservedHeight -= placeholders.length;
|
||||
olderBatches.push({ generation: olderGeneration, startHeight, placeholders });
|
||||
void hydrateOlderBatches();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param {Block} block */
|
||||
function createKnownEnteringConfirmedCube(block) {
|
||||
blocksByHash.set(block.id, block);
|
||||
|
||||
return createEnteringConfirmedCube(block, selectCube);
|
||||
projected.clear();
|
||||
older.reset();
|
||||
tip.setVisible(false);
|
||||
}
|
||||
|
||||
/** @param {Block[]} blocks */
|
||||
function appendNewerBlocks(blocks) {
|
||||
if (!blocks.length) return false;
|
||||
|
||||
const anchor = newestConfirmedCube();
|
||||
const anchor = confirmed.newest();
|
||||
const anchorRect = anchor?.getBoundingClientRect();
|
||||
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const block = blocks[i];
|
||||
|
||||
if (block.height > newestHeight) {
|
||||
appendConfirmed(createKnownEnteringConfirmedCube(block));
|
||||
confirmed.append(block);
|
||||
} else {
|
||||
blocksByHash.set(block.id, block);
|
||||
confirmed.cache(block);
|
||||
}
|
||||
}
|
||||
|
||||
newestHeight = Math.max(newestHeight, blocks[0].height);
|
||||
newestTimestamp = blocks[0].timestamp;
|
||||
updateTipCube();
|
||||
confirmed.markTip();
|
||||
refreshProjected();
|
||||
|
||||
preserveScrollPosition(scrollElement, anchor, anchorRect);
|
||||
|
||||
syncTipVisibility();
|
||||
tip.sync();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -294,16 +175,15 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
clear();
|
||||
|
||||
for (const block of blocks) {
|
||||
prependConfirmed(createKnownEnteringConfirmedCube(block));
|
||||
confirmed.prepend(block);
|
||||
}
|
||||
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
oldestReservedHeight = oldestHeight;
|
||||
older.setOldestHeight(blocks[blocks.length - 1].height);
|
||||
newestTimestamp = blocks[0].timestamp;
|
||||
reachedTip = height == null;
|
||||
updateTipCube();
|
||||
reserveOlderRunway();
|
||||
confirmed.markTip();
|
||||
older.reserve();
|
||||
|
||||
if (reachedTip) await pollProjected();
|
||||
else await loadNewer();
|
||||
@@ -316,13 +196,13 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
if (typeof hashOrHeight === "number") return hashOrHeight;
|
||||
|
||||
if (typeof hashOrHeight === "string") {
|
||||
const cached = blocksByHash.get(hashOrHeight);
|
||||
const cached = confirmed.get(hashOrHeight);
|
||||
if (cached) return cached.height;
|
||||
|
||||
const block = await brk.getBlockV1(hashOrHeight, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
blocksByHash.set(hashOrHeight, block);
|
||||
confirmed.cache(block);
|
||||
|
||||
return block.height;
|
||||
}
|
||||
@@ -338,18 +218,18 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
|
||||
const existing = findCube(hashOrHeight);
|
||||
if (existing) {
|
||||
selectCube(existing, { scroll: "smooth" });
|
||||
confirmed.select(existing, { scroll: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
markConfirmedSkeletons();
|
||||
confirmed.markSkeletons();
|
||||
element.dataset.loading = "";
|
||||
|
||||
try {
|
||||
const height = await resolveHeight(hashOrHeight);
|
||||
const startHash = await loadInitial(height);
|
||||
const cube = findCube(startHash);
|
||||
if (cube) selectCube(cube, { scroll: "instant" });
|
||||
if (cube) confirmed.select(cube, { scroll: "instant" });
|
||||
} catch (error) {
|
||||
logChainError("explore chain load:", error);
|
||||
} finally {
|
||||
@@ -382,68 +262,6 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateOlderBatches() {
|
||||
if (hydratingOlder) return;
|
||||
|
||||
const generation = olderGeneration;
|
||||
|
||||
hydratingOlder = true;
|
||||
|
||||
try {
|
||||
while (
|
||||
active &&
|
||||
generation === olderGeneration &&
|
||||
olderBatches[0]?.generation === generation
|
||||
) {
|
||||
await hydrateOlderBatch(olderBatches[0]);
|
||||
if (olderBatches[0]?.generation === generation) olderBatches.shift();
|
||||
}
|
||||
} finally {
|
||||
if (generation === olderGeneration) hydratingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {OlderBatch} batch */
|
||||
async function hydrateOlderBatch(batch) {
|
||||
try {
|
||||
const blocks = await brk.getBlocksV1FromHeight(batch.startHeight, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!batch.placeholders.some((placeholder) => placeholder.isConnected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cubes = [...blocks].reverse().map(createKnownEnteringConfirmedCube);
|
||||
|
||||
for (let i = 0; i < batch.placeholders.length; i++) {
|
||||
const cube = cubes[i];
|
||||
|
||||
if (cube) batch.placeholders[i].replaceWith(cube);
|
||||
else batch.placeholders[i].remove();
|
||||
}
|
||||
|
||||
for (const cube of cubes) setConfirmedInterval(cube);
|
||||
|
||||
const next = cubes.at(-1)?.nextElementSibling;
|
||||
if (next instanceof HTMLElement) setConfirmedInterval(next);
|
||||
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
} else {
|
||||
oldestReservedHeight = oldestHeight;
|
||||
}
|
||||
|
||||
reserveOlderRunway();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
for (const placeholder of batch.placeholders) placeholder.remove();
|
||||
oldestReservedHeight = oldestHeight;
|
||||
logChainError("explore older:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNewer() {
|
||||
if (!active || loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||
|
||||
@@ -467,104 +285,15 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {HTMLButtonElement} cube */
|
||||
function prependConfirmed(cube) {
|
||||
const oldFirst = /** @type {HTMLElement | null} */ (
|
||||
blocksElement.firstElementChild
|
||||
);
|
||||
|
||||
blocksElement.insertBefore(cube, oldFirst);
|
||||
if (oldFirst) setConfirmedInterval(oldFirst);
|
||||
}
|
||||
|
||||
/** @param {HTMLButtonElement} cube */
|
||||
function appendConfirmed(cube) {
|
||||
blocksElement.insertBefore(cube, firstProjectedCube());
|
||||
setConfirmedInterval(cube);
|
||||
}
|
||||
|
||||
/** @param {MempoolBlock[]} blocks */
|
||||
function renderProjected(blocks) {
|
||||
const want = Math.min(blocks.length, PROJECTED_LIMIT);
|
||||
|
||||
while (projectedCubes.length > want) {
|
||||
projectedCubes.pop()?.element.remove();
|
||||
}
|
||||
|
||||
while (projectedCubes.length < want) {
|
||||
const cube = createProjectedCube();
|
||||
projectedCubes.push(cube);
|
||||
blocksElement.append(cube.element);
|
||||
}
|
||||
|
||||
for (let i = 0; i < want; i++) {
|
||||
updateProjectedCube(projectedCubes[i], blocks[i]);
|
||||
}
|
||||
|
||||
updateTipCube();
|
||||
projected.render(blocks);
|
||||
confirmed.markTip();
|
||||
refreshProjected();
|
||||
}
|
||||
|
||||
function refreshProjected() {
|
||||
if (!projectedCubes.length || !newestTimestamp) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const elapsed = Math.max(0, now - newestTimestamp);
|
||||
const updateLayout = !tipButton.hasAttribute("data-visible");
|
||||
|
||||
for (let i = 0; i < projectedCubes.length; i++) {
|
||||
const cube = projectedCubes[i];
|
||||
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
|
||||
const timestamp = now + i * TARGET_BLOCK_SECONDS;
|
||||
|
||||
if (updateLayout) {
|
||||
cube.element.style.setProperty("--block-interval", String(interval));
|
||||
}
|
||||
|
||||
updateProjectedTime(cube, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTipVisibilitySync() {
|
||||
if (tipSyncFrame) return;
|
||||
|
||||
tipSyncFrame = window.requestAnimationFrame(() => {
|
||||
tipSyncFrame = 0;
|
||||
syncTipVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
function cancelTipVisibilitySync() {
|
||||
if (!tipSyncFrame) return;
|
||||
|
||||
window.cancelAnimationFrame(tipSyncFrame);
|
||||
tipSyncFrame = 0;
|
||||
}
|
||||
|
||||
/** @param {boolean} visible */
|
||||
function setTipVisible(visible) {
|
||||
tipButton.toggleAttribute("data-visible", visible);
|
||||
tipButton.setAttribute("aria-hidden", String(!visible));
|
||||
tipButton.tabIndex = visible ? 0 : -1;
|
||||
}
|
||||
|
||||
function syncTipVisibility() {
|
||||
if (!reachedTip || newestHeight < 0 || !tipCube) {
|
||||
setTipVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeight = findVisibleConfirmedHeight(scrollElement, blocksElement);
|
||||
if (projectedCubes.some(({ element }) => isElementVisible(element))) {
|
||||
setTipVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTipVisible(
|
||||
visibleHeight != null
|
||||
? newestHeight - visibleHeight > TIP_BLOCK_THRESHOLD
|
||||
: !isElementVisible(tipCube),
|
||||
);
|
||||
projected.refresh(newestTimestamp);
|
||||
}
|
||||
|
||||
/** @param {Element} element */
|
||||
@@ -578,7 +307,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
}
|
||||
|
||||
function shouldLoadNewer() {
|
||||
const cube = newestConfirmedCube();
|
||||
const cube = confirmed.newest();
|
||||
|
||||
return cube != null && cubeDistanceFromViewport(cube) <= EDGE_LOAD_DISTANCE;
|
||||
}
|
||||
@@ -586,7 +315,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
scrollElement.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
reserveOlderRunway(olderWheelDelta(event, isHorizontal()));
|
||||
older.reserve(olderWheelDelta(event, isHorizontal()));
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -594,8 +323,8 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
scrollElement.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
scheduleTipVisibilitySync();
|
||||
reserveOlderRunway();
|
||||
tip.schedule();
|
||||
older.reserve();
|
||||
|
||||
if (reachedTip || loadingNewer) return;
|
||||
if (shouldLoadNewer()) void loadNewer();
|
||||
@@ -624,7 +353,7 @@ export function createChain({ onSelect = () => {} } = {}) {
|
||||
pollId = undefined;
|
||||
}
|
||||
|
||||
cancelTipVisibilitySync();
|
||||
tip.cancel();
|
||||
jump.cancel();
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createPlaceholderCube, setConfirmedInterval } from "./block-cube.js";
|
||||
import { olderRemaining, olderRunway } from "./scroll.js";
|
||||
|
||||
const OLDER_RESERVE_VIEWPORTS = 6;
|
||||
|
||||
/**
|
||||
* @typedef {import("../../modules/brk-client/index.js").BlockInfoV1} Block
|
||||
* @typedef {{ generation: number, startHeight: number, placeholders: HTMLElement[] }} OlderBatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.scrollElement
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {number} args.batchSize
|
||||
* @param {() => boolean} args.isActive
|
||||
* @param {() => boolean} args.isHorizontal
|
||||
* @param {(startHeight: number) => Promise<Block[]>} args.fetchBlocks
|
||||
* @param {(block: Block) => HTMLButtonElement} args.createCube
|
||||
* @param {() => boolean} args.isAborted
|
||||
* @param {(error: unknown) => void} args.onError
|
||||
*/
|
||||
export function createOlderBlocks({
|
||||
scrollElement,
|
||||
blocksElement,
|
||||
batchSize,
|
||||
isActive,
|
||||
isHorizontal,
|
||||
fetchBlocks,
|
||||
createCube,
|
||||
isAborted,
|
||||
onError,
|
||||
}) {
|
||||
let oldestHeight = Infinity;
|
||||
let oldestReservedHeight = -1;
|
||||
let hydrating = false;
|
||||
let generation = 0;
|
||||
/** @type {OlderBatch[]} */
|
||||
const batches = [];
|
||||
|
||||
function reset() {
|
||||
oldestHeight = Infinity;
|
||||
oldestReservedHeight = -1;
|
||||
hydrating = false;
|
||||
generation++;
|
||||
batches.length = 0;
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
function setOldestHeight(height) {
|
||||
oldestHeight = height;
|
||||
oldestReservedHeight = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element | null} anchor
|
||||
* @param {number} count
|
||||
*/
|
||||
function prependPlaceholders(anchor, count) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const placeholders = /** @type {HTMLElement[]} */ ([]);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cube = createPlaceholderCube();
|
||||
|
||||
placeholders.push(cube);
|
||||
fragment.append(cube);
|
||||
}
|
||||
|
||||
blocksElement.insertBefore(fragment, anchor);
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
function reserveBatch() {
|
||||
if (!isActive() || oldestReservedHeight <= 0) return false;
|
||||
|
||||
const anchor = blocksElement.firstElementChild;
|
||||
const count = Math.min(batchSize, oldestReservedHeight);
|
||||
const startHeight = oldestReservedHeight - 1;
|
||||
const placeholders = prependPlaceholders(anchor, count);
|
||||
|
||||
if (!placeholders.length) return false;
|
||||
|
||||
oldestReservedHeight -= placeholders.length;
|
||||
batches.push({ generation, startHeight, placeholders });
|
||||
void hydrateBatches();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param {number} [delta] */
|
||||
function reserve(delta = 0) {
|
||||
if (!isActive() || oldestReservedHeight <= 0) return;
|
||||
|
||||
const horizontal = isHorizontal();
|
||||
const runway =
|
||||
olderRunway(scrollElement, horizontal, OLDER_RESERVE_VIEWPORTS) + delta;
|
||||
let remaining = olderRemaining(scrollElement, horizontal);
|
||||
|
||||
while (remaining < runway) {
|
||||
if (!reserveBatch()) return;
|
||||
remaining = olderRemaining(scrollElement, horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateBatches() {
|
||||
if (hydrating) return;
|
||||
|
||||
const currentGeneration = generation;
|
||||
|
||||
hydrating = true;
|
||||
|
||||
try {
|
||||
while (
|
||||
isActive() &&
|
||||
currentGeneration === generation &&
|
||||
batches[0]?.generation === currentGeneration
|
||||
) {
|
||||
await hydrateBatch(batches[0]);
|
||||
if (batches[0]?.generation === currentGeneration) batches.shift();
|
||||
}
|
||||
} finally {
|
||||
if (currentGeneration === generation) hydrating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {OlderBatch} batch */
|
||||
async function hydrateBatch(batch) {
|
||||
try {
|
||||
const blocks = await fetchBlocks(batch.startHeight);
|
||||
|
||||
if (
|
||||
batch.generation !== generation ||
|
||||
!batch.placeholders.some((placeholder) => placeholder.isConnected)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cubes = [...blocks].reverse().map(createCube);
|
||||
|
||||
for (let i = 0; i < batch.placeholders.length; i++) {
|
||||
const cube = cubes[i];
|
||||
|
||||
if (cube) batch.placeholders[i].replaceWith(cube);
|
||||
else batch.placeholders[i].remove();
|
||||
}
|
||||
|
||||
for (const cube of cubes) setConfirmedInterval(cube);
|
||||
|
||||
const next = cubes.at(-1)?.nextElementSibling;
|
||||
if (next instanceof HTMLElement) setConfirmedInterval(next);
|
||||
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
} else {
|
||||
oldestReservedHeight = oldestHeight;
|
||||
}
|
||||
|
||||
reserve();
|
||||
} catch (error) {
|
||||
if (isAborted() || batch.generation !== generation) return;
|
||||
|
||||
for (const placeholder of batch.placeholders) placeholder.remove();
|
||||
oldestReservedHeight = oldestHeight;
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
reserve,
|
||||
reset,
|
||||
setOldestHeight,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
createProjectedCube,
|
||||
updateProjectedCube,
|
||||
updateProjectedTime,
|
||||
} from "./block-cube.js";
|
||||
|
||||
const PROJECTED_LIMIT = 8;
|
||||
const TARGET_BLOCK_SECONDS = 600;
|
||||
|
||||
/** @typedef {import("../../modules/brk-client/index.js").MempoolBlock} MempoolBlock */
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLElement} args.blocksElement
|
||||
* @param {() => boolean} args.isLayoutFrozen
|
||||
* @param {(element: Element) => boolean} args.isElementVisible
|
||||
*/
|
||||
export function createProjectedBlocks({
|
||||
blocksElement,
|
||||
isLayoutFrozen,
|
||||
isElementVisible,
|
||||
}) {
|
||||
/** @type {ReturnType<typeof createProjectedCube>[]} */
|
||||
const cubes = [];
|
||||
|
||||
function firstElement() {
|
||||
return cubes[0]?.element ?? null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cubes.length = 0;
|
||||
}
|
||||
|
||||
/** @param {MempoolBlock[]} blocks */
|
||||
function render(blocks) {
|
||||
const want = Math.min(blocks.length, PROJECTED_LIMIT);
|
||||
|
||||
while (cubes.length > want) {
|
||||
cubes.pop()?.element.remove();
|
||||
}
|
||||
|
||||
while (cubes.length < want) {
|
||||
const cube = createProjectedCube();
|
||||
|
||||
cubes.push(cube);
|
||||
blocksElement.append(cube.element);
|
||||
}
|
||||
|
||||
for (let i = 0; i < want; i++) {
|
||||
updateProjectedCube(cubes[i], blocks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} newestTimestamp */
|
||||
function refresh(newestTimestamp) {
|
||||
if (!cubes.length || !newestTimestamp) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const elapsed = Math.max(0, now - newestTimestamp);
|
||||
const updateLayout = !isLayoutFrozen();
|
||||
|
||||
for (let i = 0; i < cubes.length; i++) {
|
||||
const cube = cubes[i];
|
||||
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
|
||||
const timestamp = now + i * TARGET_BLOCK_SECONDS;
|
||||
|
||||
if (updateLayout) {
|
||||
cube.element.style.setProperty("--block-interval", String(interval));
|
||||
}
|
||||
|
||||
updateProjectedTime(cube, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function hasVisibleElement() {
|
||||
return cubes.some(({ element }) => isElementVisible(element));
|
||||
}
|
||||
|
||||
return /** @type {const} */ ({
|
||||
clear,
|
||||
firstElement,
|
||||
hasVisibleElement,
|
||||
refresh,
|
||||
render,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const TIP_BLOCK_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HTMLButtonElement} args.button
|
||||
* @param {() => boolean} args.reachedTip
|
||||
* @param {() => number} args.newestHeight
|
||||
* @param {() => HTMLButtonElement | null} args.tipCube
|
||||
* @param {() => number | null} args.visibleConfirmedHeight
|
||||
* @param {() => boolean} args.hasVisibleProjected
|
||||
* @param {(element: Element) => boolean} args.isElementVisible
|
||||
*/
|
||||
export function createTipVisibility({
|
||||
button,
|
||||
reachedTip,
|
||||
newestHeight,
|
||||
tipCube,
|
||||
visibleConfirmedHeight,
|
||||
hasVisibleProjected,
|
||||
isElementVisible,
|
||||
}) {
|
||||
let frame = 0;
|
||||
|
||||
/** @param {boolean} visible */
|
||||
function setVisible(visible) {
|
||||
button.toggleAttribute("data-visible", visible);
|
||||
button.setAttribute("aria-hidden", String(!visible));
|
||||
button.tabIndex = visible ? 0 : -1;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const cube = tipCube();
|
||||
const height = newestHeight();
|
||||
|
||||
if (!reachedTip() || height < 0 || !cube) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeight = visibleConfirmedHeight();
|
||||
if (hasVisibleProjected()) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(
|
||||
visibleHeight != null
|
||||
? height - visibleHeight > TIP_BLOCK_THRESHOLD
|
||||
: !isElementVisible(cube),
|
||||
);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (frame) return;
|
||||
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!frame) return;
|
||||
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
|
||||
return /** @type {const} */ ({
|
||||
cancel,
|
||||
schedule,
|
||||
setVisible,
|
||||
sync,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user