mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-18 02:39:43 -07:00
global: snap
This commit is contained in:
@@ -55,9 +55,12 @@
|
||||
* @typedef {Brk._0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern} Ratio1ySdPattern
|
||||
* @typedef {Brk.Dollars} Dollars
|
||||
* @typedef {Brk.BlockInfo} BlockInfo
|
||||
* @typedef {Brk.Height} Height
|
||||
* @typedef {Brk.BlockHash} BlockHash
|
||||
* @typedef {Brk.BlockInfoV1} BlockInfoV1
|
||||
* @typedef {Brk.Transaction} Transaction
|
||||
* @typedef {Brk.Txid} Txid
|
||||
* @typedef {Brk.TxIndex} TxIndex
|
||||
* @typedef {Brk.AddrStats} AddrStats
|
||||
* @typedef {Brk.TxIn} TxIn
|
||||
* @typedef {Brk.TxOut} TxOut
|
||||
|
||||
@@ -56,10 +56,16 @@ export function initChain(parent, callbacks) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} hash */
|
||||
export function findCube(hash) {
|
||||
/** @param {BlockHash | Height | null} [hashOrHeight] */
|
||||
function findCube(hashOrHeight) {
|
||||
if (hashOrHeight == null) {
|
||||
return reachedTip && newestHeight >= 0
|
||||
? /** @type {HTMLDivElement | null} */ (blocksEl.lastElementChild)
|
||||
: null;
|
||||
}
|
||||
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||
return /** @type {HTMLDivElement | null} */ (
|
||||
blocksEl.querySelector(`[data-hash="${hash}"]`)
|
||||
blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +92,7 @@ export function selectCube(cube, { scroll, silent } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
function clear() {
|
||||
newestHeight = -1;
|
||||
oldestHeight = Infinity;
|
||||
loadingOlder = false;
|
||||
@@ -126,12 +132,13 @@ function appendNewerBlocks(blocks) {
|
||||
}
|
||||
|
||||
/** @param {number | null} [height] @returns {Promise<BlockHash>} */
|
||||
export async function loadInitial(height) {
|
||||
async function loadInitial(height) {
|
||||
const blocks =
|
||||
height != null
|
||||
? await brk.getBlocksV1FromHeight(height)
|
||||
: await brk.getBlocksV1();
|
||||
|
||||
clear();
|
||||
for (const b of blocks) blocksEl.prepend(createBlockCube(b));
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
@@ -141,6 +148,39 @@ export async function loadInitial(height) {
|
||||
return blocks[0].id;
|
||||
}
|
||||
|
||||
/** @param {BlockHash | Height | null} [hashOrHeight] @returns {Promise<Height | null>} */
|
||||
async function resolveHeight(hashOrHeight) {
|
||||
if (typeof hashOrHeight === "number") return hashOrHeight;
|
||||
if (typeof hashOrHeight === "string") {
|
||||
const cached = blocksByHash.get(hashOrHeight);
|
||||
if (cached) return cached.height;
|
||||
const block = await brk.getBlockV1(hashOrHeight);
|
||||
blocksByHash.set(hashOrHeight, block);
|
||||
return block.height;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */
|
||||
export async function goToCube(hashOrHeight, { silent } = {}) {
|
||||
if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) {
|
||||
hashOrHeight = Number(hashOrHeight);
|
||||
}
|
||||
let cube = findCube(hashOrHeight);
|
||||
if (cube) {
|
||||
selectCube(cube, { scroll: "smooth", silent });
|
||||
return;
|
||||
}
|
||||
let startHash;
|
||||
try {
|
||||
const height = await resolveHeight(hashOrHeight);
|
||||
startHash = await loadInitial(height);
|
||||
} catch (e) {
|
||||
try { startHash = await loadInitial(null); } catch (_) { return; }
|
||||
}
|
||||
selectCube(/** @type {HTMLDivElement} */ (findCube(startHash)), { scroll: "instant", silent });
|
||||
}
|
||||
|
||||
export async function poll() {
|
||||
if (newestHeight === -1 || !reachedTip) return;
|
||||
try {
|
||||
@@ -185,6 +225,7 @@ function createBlockCube(block) {
|
||||
createCube();
|
||||
|
||||
cubeElement.dataset.hash = block.id;
|
||||
cubeElement.dataset.height = String(block.height);
|
||||
blocksByHash.set(block.id, block);
|
||||
cubeElement.addEventListener("click", () => onCubeClick(cubeElement));
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@ import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import {
|
||||
initChain,
|
||||
loadInitial,
|
||||
goToCube,
|
||||
poll,
|
||||
selectCube,
|
||||
deselectCube,
|
||||
findCube,
|
||||
clear as clearChain,
|
||||
} from "./chain.js";
|
||||
import {
|
||||
initBlockDetails,
|
||||
@@ -38,10 +36,12 @@ function pathSegments() {
|
||||
/** @type {number | undefined} */ let pollInterval;
|
||||
let navController = new AbortController();
|
||||
const txCache = createMapCache(50);
|
||||
let lastLoadedUrl = "";
|
||||
|
||||
function navigate() {
|
||||
navController.abort();
|
||||
navController = new AbortController();
|
||||
lastLoadedUrl = window.location.pathname;
|
||||
return navController.signal;
|
||||
}
|
||||
|
||||
@@ -60,27 +60,27 @@ function handleLinkClick(e) {
|
||||
const m = a.pathname.match(/^\/(block|tx|address)\/(.+)/);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
history.pushState(null, "", a.href);
|
||||
if (m[1] === "block") {
|
||||
navigateToBlock(m[2]);
|
||||
} else if (m[1] === "tx") {
|
||||
history.pushState(null, "", a.href);
|
||||
navigateToTx(m[2]);
|
||||
} else {
|
||||
history.pushState(null, "", a.href);
|
||||
navigateToAddr(m[2]);
|
||||
}
|
||||
}
|
||||
|
||||
export function init() {
|
||||
/** @param {{ onChange: (cb: (option: Option) => void) => void }} selected */
|
||||
export function init(selected) {
|
||||
initChain(explorerElement, {
|
||||
onSelect: (block) => {
|
||||
updateBlock(block);
|
||||
showPanel("block");
|
||||
},
|
||||
onCubeClick: (cube) => {
|
||||
navigate();
|
||||
const hash = cube.dataset.hash;
|
||||
if (hash) history.pushState(null, "", `/block/${hash}`);
|
||||
navigate();
|
||||
selectCube(cube);
|
||||
},
|
||||
});
|
||||
@@ -101,15 +101,12 @@ export function init() {
|
||||
if (!document.hidden && !explorerElement.hidden) poll();
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
const [kind, value] = pathSegments();
|
||||
if (kind === "block" && value) navigateToBlock(value, false);
|
||||
else if (kind === "tx" && value) navigateToTx(value);
|
||||
else if (kind === "address" && value) navigateToAddr(value);
|
||||
else showPanel("block");
|
||||
selected.onChange((option) => {
|
||||
if (option.kind === "explorer") {
|
||||
const url = window.location.pathname;
|
||||
if (url !== lastLoadedUrl) load();
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
@@ -126,98 +123,77 @@ function stopPolling() {
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const signal = navigate();
|
||||
try {
|
||||
const [kind, value] = pathSegments();
|
||||
|
||||
if (kind === "tx" && value) {
|
||||
const tx = txCache.get(value) ?? (await brk.getTx(value));
|
||||
txCache.set(value, tx);
|
||||
const startHash = await loadInitial(tx.status?.blockHeight ?? null);
|
||||
const cube = tx.status?.blockHash ? findCube(tx.status.blockHash) : findCube(startHash);
|
||||
if (cube) selectCube(cube, { silent: true });
|
||||
const txid = await resolveTxid(value, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
|
||||
if (signal.aborted) return;
|
||||
txCache.set(txid, tx);
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
showPanel("tx");
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "address" && value) {
|
||||
const startHash = await loadInitial(null);
|
||||
const cube = findCube(startHash);
|
||||
if (cube) selectCube(cube, { silent: true });
|
||||
await goToCube(null, { silent: true });
|
||||
navigateToAddr(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const height =
|
||||
kind === "block" && value
|
||||
? /^\d+$/.test(value)
|
||||
? Number(value)
|
||||
: (await brk.getBlockV1(value)).height
|
||||
: null;
|
||||
const startHash = await loadInitial(height);
|
||||
const cube = findCube(startHash);
|
||||
if (cube) selectCube(cube, { scroll: "instant" });
|
||||
await goToCube(kind === "block" ? value : null);
|
||||
} catch (e) {
|
||||
if (signal.aborted) return;
|
||||
console.error("explorer load:", e);
|
||||
await goToCube();
|
||||
showPanel("block");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} hash @param {boolean} [pushUrl] */
|
||||
async function navigateToBlock(hash, pushUrl = true) {
|
||||
if (pushUrl) history.pushState(null, "", `/block/${hash}`);
|
||||
const existing = findCube(hash);
|
||||
if (existing) {
|
||||
navigate();
|
||||
selectCube(existing, { scroll: "smooth" });
|
||||
return;
|
||||
}
|
||||
/** @param {string} hashOrHeight */
|
||||
async function navigateToBlock(hashOrHeight) {
|
||||
const signal = navigate();
|
||||
try {
|
||||
clearChain();
|
||||
const height = /^\d+$/.test(hash)
|
||||
? Number(hash)
|
||||
: (await brk.getBlockV1(hash, { signal })).height;
|
||||
if (signal.aborted) return;
|
||||
const startHash = await loadInitial(height);
|
||||
if (signal.aborted) return;
|
||||
const cube = findCube(hash) ?? findCube(startHash);
|
||||
if (cube) selectCube(cube);
|
||||
} catch (e) {
|
||||
if (!signal.aborted) console.error("explorer block:", e);
|
||||
}
|
||||
await goToCube(hashOrHeight);
|
||||
if (!signal.aborted) showPanel("block");
|
||||
}
|
||||
|
||||
/** @param {string} txid */
|
||||
async function navigateToTx(txid) {
|
||||
/** @param {Txid | TxIndex} value @param {{ signal?: AbortSignal }} [options] */
|
||||
async function resolveTxid(value, { signal } = {}) {
|
||||
return typeof value === "number" || /^\d+$/.test(value)
|
||||
? await brk.getTxByIndex(Number(value), { signal })
|
||||
: value;
|
||||
}
|
||||
|
||||
/** @param {Txid | TxIndex} txidOrIndex */
|
||||
async function navigateToTx(txidOrIndex) {
|
||||
const signal = navigate();
|
||||
clearTx();
|
||||
showPanel("tx");
|
||||
try {
|
||||
const txid = await resolveTxid(txidOrIndex, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
|
||||
if (signal.aborted) return;
|
||||
txCache.set(txid, tx);
|
||||
|
||||
if (tx.status?.blockHash) {
|
||||
let cube = findCube(tx.status.blockHash);
|
||||
if (!cube) {
|
||||
clearChain();
|
||||
const startHash = await loadInitial(tx.status.blockHeight ?? null);
|
||||
if (signal.aborted) return;
|
||||
cube = findCube(tx.status.blockHash) ?? findCube(startHash);
|
||||
}
|
||||
if (cube) selectCube(cube, { scroll: "smooth", silent: true });
|
||||
}
|
||||
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
} catch (e) {
|
||||
if (!signal.aborted) console.error("explorer tx:", e);
|
||||
if (!signal.aborted) {
|
||||
console.error("explorer tx:", e);
|
||||
await goToCube();
|
||||
showPanel("block");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} address */
|
||||
function navigateToAddr(address) {
|
||||
navigate();
|
||||
const signal = navigate();
|
||||
deselectCube();
|
||||
updateAddr(address, navController.signal);
|
||||
updateAddr(address, signal);
|
||||
showPanel("addr");
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function initSelected() {
|
||||
element = explorerElement;
|
||||
|
||||
if (firstTimeLoadingExplorer) {
|
||||
initExplorer();
|
||||
initExplorer(options.selected);
|
||||
}
|
||||
firstTimeLoadingExplorer = false;
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ export function initOptions() {
|
||||
highlightedLis.push(li);
|
||||
}
|
||||
}
|
||||
if (!highlightedLis.length) {
|
||||
const li = liByPath.get(stringToId(sel.name));
|
||||
if (li) {
|
||||
li.dataset.highlight = "";
|
||||
highlightedLis.push(li);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selected = {
|
||||
@@ -360,8 +367,9 @@ export function initOptions() {
|
||||
/**
|
||||
* @param {ProcessedNode[]} nodes
|
||||
* @param {HTMLElement} parentEl
|
||||
* @param {boolean} autoOpen
|
||||
*/
|
||||
function buildTreeDOM(nodes, parentEl) {
|
||||
function buildTreeDOM(nodes, parentEl, autoOpen) {
|
||||
const ul = window.document.createElement("ul");
|
||||
|
||||
for (const node of nodes) {
|
||||
@@ -370,8 +378,6 @@ export function initOptions() {
|
||||
|
||||
liByPath.set(node.pathKey, li);
|
||||
|
||||
const onSelectedPath = isOnSelectedPath(node.path);
|
||||
|
||||
if (node.type === "group") {
|
||||
const details = window.document.createElement("details");
|
||||
details.dataset.name = node.serName;
|
||||
@@ -386,16 +392,17 @@ export function initOptions() {
|
||||
summary.append(count);
|
||||
|
||||
let built = false;
|
||||
if (onSelectedPath) {
|
||||
if (autoOpen && isOnSelectedPath(node.path)) {
|
||||
built = true;
|
||||
details.open = true;
|
||||
buildTreeDOM(node.children, details);
|
||||
buildTreeDOM(node.children, details, true);
|
||||
}
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open && !built) {
|
||||
built = true;
|
||||
buildTreeDOM(node.children, details);
|
||||
buildTreeDOM(node.children, details, false);
|
||||
}
|
||||
updateHighlight(selected.value);
|
||||
});
|
||||
} else {
|
||||
const element = createOptionElement({
|
||||
@@ -417,7 +424,7 @@ export function initOptions() {
|
||||
function setParent(el) {
|
||||
if (parentEl) return;
|
||||
parentEl = el;
|
||||
buildTreeDOM(processedTree, el);
|
||||
buildTreeDOM(processedTree, el, true);
|
||||
updateHighlight(selected.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,44 +18,104 @@ export function init(options) {
|
||||
|
||||
const matcher = new QuickMatch(haystack);
|
||||
|
||||
/** @type {HTMLLIElement | undefined} */
|
||||
let highlighted;
|
||||
|
||||
/** @param {HTMLLIElement} [li] */
|
||||
function setHighlight(li) {
|
||||
if (highlighted) delete highlighted.dataset.highlight;
|
||||
highlighted = li;
|
||||
if (li) li.dataset.highlight = "";
|
||||
}
|
||||
|
||||
function inputEvent() {
|
||||
const needle = /** @type {string} */ (searchInput.value).trim();
|
||||
|
||||
searchResultsElement.scrollTo({ top: 0 });
|
||||
searchResultsElement.innerHTML = "";
|
||||
setHighlight();
|
||||
|
||||
if (!needle.length) {
|
||||
return;
|
||||
}
|
||||
if (!needle.length) return;
|
||||
|
||||
const matches = matcher.matches(needle);
|
||||
|
||||
if (!matches.length) {
|
||||
const indexMatch = needle.match(
|
||||
/^(?:(block|b)|(transaction|tx))?\s*#?\s*(\d+)$/i,
|
||||
);
|
||||
|
||||
if (indexMatch) {
|
||||
const num = indexMatch[3];
|
||||
const entries = indexMatch[1]
|
||||
? [["Block", `/block/${num}`]]
|
||||
: indexMatch[2]
|
||||
? [["Transaction", `/tx/${num}`]]
|
||||
: [
|
||||
["Block", `/block/${num}`],
|
||||
["Transaction", `/tx/${num}`],
|
||||
];
|
||||
for (const [label, href] of entries) {
|
||||
const li = window.document.createElement("li");
|
||||
const a = window.document.createElement("a");
|
||||
a.href = href;
|
||||
a.textContent = `${label} #${num}`;
|
||||
a.title = `${label} #${num}`;
|
||||
if (href === window.location.pathname) setHighlight(li);
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
setHighlight(li);
|
||||
history.pushState(null, "", href);
|
||||
options.resolveUrl();
|
||||
});
|
||||
li.append(a);
|
||||
searchResultsElement.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length) {
|
||||
matches.forEach((title) => {
|
||||
const option = titleToOption.get(title);
|
||||
if (!option) return;
|
||||
|
||||
const li = window.document.createElement("li");
|
||||
searchResultsElement.appendChild(li);
|
||||
|
||||
if (option === options.selected.value) setHighlight(li);
|
||||
|
||||
const element = options.createOptionElement({
|
||||
option,
|
||||
name: option.title,
|
||||
});
|
||||
|
||||
if (element) li.append(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (!searchResultsElement.children.length) {
|
||||
const li = window.document.createElement("li");
|
||||
li.textContent = "No results";
|
||||
li.style.color = "var(--off-color)";
|
||||
searchResultsElement.appendChild(li);
|
||||
return;
|
||||
}
|
||||
|
||||
matches.forEach((title) => {
|
||||
const option = titleToOption.get(title);
|
||||
if (!option) return;
|
||||
|
||||
const li = window.document.createElement("li");
|
||||
searchResultsElement.appendChild(li);
|
||||
|
||||
const element = options.createOptionElement({
|
||||
option,
|
||||
name: option.title,
|
||||
});
|
||||
|
||||
if (element) {
|
||||
li.append(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
options.selected.onChange(() => {
|
||||
const selected = options.selected.value;
|
||||
const href =
|
||||
selected?.kind === "explorer"
|
||||
? window.location.pathname
|
||||
: selected?.path.length
|
||||
? `/${selected.path.join("/")}`
|
||||
: null;
|
||||
if (!href) return setHighlight();
|
||||
for (const li of searchResultsElement.children) {
|
||||
const a = li.querySelector("a");
|
||||
if (a && a.getAttribute("href") === href) {
|
||||
return setHighlight(/** @type {HTMLLIElement} */ (li));
|
||||
}
|
||||
}
|
||||
setHighlight();
|
||||
});
|
||||
|
||||
inputEvent();
|
||||
|
||||
searchInput.addEventListener("input", inputEvent);
|
||||
|
||||
Reference in New Issue
Block a user