mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 22:34:46 -07:00
website: snap
This commit is contained in:
@@ -21,7 +21,7 @@ Open-source Bitcoin data toolkit that can parse blocks, index the chain, compute
|
|||||||
|
|
||||||
**Blockchain:** Blocks, transactions, addresses, UTXOs.
|
**Blockchain:** Blocks, transactions, addresses, UTXOs.
|
||||||
|
|
||||||
**Metrics:** Supply distributions, holder cohorts, network activity, fee markets, mining, and market indicators (realized cap, MVRV, SOPR, NVT).
|
**Metrics:** Supply distributions, holder cohorts, network activity, fee markets, mining, and market indicators.
|
||||||
|
|
||||||
**Indexes:** Date, height, halving epoch, address type, UTXO age.
|
**Indexes:** Date, height, halving epoch, address type, UTXO age.
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ Build custom applications in Rust. Use the full stack or individual components (
|
|||||||
|
|
||||||
## Supporters
|
## Supporters
|
||||||
|
|
||||||
- [OpenSats](https://opensats.org/) (December 2024 - June 2026)
|
- [OpenSats](https://opensats.org/) (December 2024 - May 2027)
|
||||||
|
|
||||||
[Become a supporter](mailto:support@bitcoinresearchkit.org)
|
[Become a supporter](mailto:support@bitcoinresearchkit.org)
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,19 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
<!-- IMPORTMAP -->
|
<!-- IMPORTMAP -->
|
||||||
<link rel="stylesheet" href="/styles/reset.css" />
|
<link rel="stylesheet" href="/src/explorer/chain/cube/style.css" />
|
||||||
<link rel="stylesheet" href="/styles/fonts.css" />
|
<link rel="stylesheet" href="/src/explorer/chain/style.css" />
|
||||||
<link rel="stylesheet" href="/styles/variables.css" />
|
<link rel="stylesheet" href="/styles/chart.css" />
|
||||||
<link rel="stylesheet" href="/styles/elements.css" />
|
|
||||||
<link rel="stylesheet" href="/styles/components.css" />
|
<link rel="stylesheet" href="/styles/components.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/elements.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/fonts.css" />
|
||||||
<link rel="stylesheet" href="/styles/main.css" />
|
<link rel="stylesheet" href="/styles/main.css" />
|
||||||
<link rel="stylesheet" href="/styles/nav.css" />
|
<link rel="stylesheet" href="/styles/nav.css" />
|
||||||
<link rel="stylesheet" href="/styles/search.css" />
|
|
||||||
<link rel="stylesheet" href="/styles/chart.css" />
|
|
||||||
<link rel="stylesheet" href="/styles/panes/chart.css" />
|
<link rel="stylesheet" href="/styles/panes/chart.css" />
|
||||||
<link rel="stylesheet" href="/styles/panes/explorer.css" />
|
<link rel="stylesheet" href="/styles/panes/explorer.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/reset.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/search.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/variables.css" />
|
||||||
<!-- /IMPORTMAP -->
|
<!-- /IMPORTMAP -->
|
||||||
|
|
||||||
<!-- ------- -->
|
<!-- ------- -->
|
||||||
@@ -97,9 +99,18 @@
|
|||||||
<script type="module" src="/scripts/entry.js"></script>
|
<script type="module" src="/scripts/entry.js"></script>
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png" />
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="96x96"
|
||||||
|
href="/assets/favicon/favicon-96x96.png"
|
||||||
|
/>
|
||||||
<link rel="shortcut icon" href="/assets/favicon/favicon.ico" />
|
<link rel="shortcut icon" href="/assets/favicon/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/assets/favicon/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
<meta name="apple-mobile-web-app-title" content="bitview" />
|
<meta name="apple-mobile-web-app-title" content="bitview" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -63,6 +63,9 @@
|
|||||||
* @typedef {Brk.AddrStats} AddrStats
|
* @typedef {Brk.AddrStats} AddrStats
|
||||||
* @typedef {Brk.TxIn} TxIn
|
* @typedef {Brk.TxIn} TxIn
|
||||||
* @typedef {Brk.TxOut} TxOut
|
* @typedef {Brk.TxOut} TxOut
|
||||||
|
* @typedef {Brk.BlockTemplate} BlockTemplate
|
||||||
|
* @typedef {Brk.MempoolBlock} MempoolBlock
|
||||||
|
* @typedef {Brk.NextBlockHash} NextBlockHash
|
||||||
* ActivePriceRatioPattern: ratio pattern with price (extended)
|
* ActivePriceRatioPattern: ratio pattern with price (extended)
|
||||||
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
|
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
|
||||||
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)
|
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { brk } from "../utils/client.js";
|
import { brk } from "../utils/client.js";
|
||||||
import { onPlainClick } from "../utils/dom.js";
|
import { onPlainClick } from "../utils/dom.js";
|
||||||
import { createCube } from "./cube.js";
|
import { createCube } from "./cube.js";
|
||||||
|
import { initMempool, renderMempool } from "./mempool.js";
|
||||||
import { createHeightElement, formatFeeRate } from "./render.js";
|
import { createHeightElement, formatFeeRate } from "./render.js";
|
||||||
|
|
||||||
const LOOKAHEAD = 15;
|
const LOOKAHEAD = 15;
|
||||||
@@ -59,6 +60,8 @@ export function initChain(parent, callbacks) {
|
|||||||
blocksEl.classList.add("blocks");
|
blocksEl.classList.add("blocks");
|
||||||
scrollEl.append(blocksEl);
|
scrollEl.append(blocksEl);
|
||||||
|
|
||||||
|
initMempool(scrollEl);
|
||||||
|
|
||||||
olderObserver = new IntersectionObserver(
|
olderObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting) loadOlder();
|
if (entries[0].isIntersecting) loadOlder();
|
||||||
@@ -208,7 +211,10 @@ export async function goToCube(hashOrHeight, { silent } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function poll() {
|
export async function poll() {
|
||||||
if (newestHeight === -1 || !reachedTip) return;
|
if (!reachedTip) return;
|
||||||
|
brk.getMempoolBlocks()
|
||||||
|
.then(renderMempool)
|
||||||
|
.catch((e) => console.error("mempool poll:", e));
|
||||||
try {
|
try {
|
||||||
const blocks = await brk.getBlocksV1();
|
const blocks = await brk.getBlocksV1();
|
||||||
appendNewerBlocks(blocks);
|
appendNewerBlocks(blocks);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
poll,
|
poll,
|
||||||
selectCube,
|
selectCube,
|
||||||
deselectCube,
|
deselectCube,
|
||||||
} from "./chain.js";
|
} from "../../src/explorer/chain/index.js";
|
||||||
import {
|
import {
|
||||||
initBlockDetails,
|
initBlockDetails,
|
||||||
update as updateBlock,
|
update as updateBlock,
|
||||||
|
|||||||
89
website/scripts/explorer/mempool.js
Normal file
89
website/scripts/explorer/mempool.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { createCube } from "./cube.js";
|
||||||
|
import { formatFeeRate } from "./render.js";
|
||||||
|
|
||||||
|
const NUM_BLOCKS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* el: HTMLElement,
|
||||||
|
* topFace: HTMLDivElement,
|
||||||
|
* rightFace: HTMLDivElement,
|
||||||
|
* leftFace: HTMLDivElement,
|
||||||
|
* }} Cube
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {HTMLDivElement | null} */ let mempoolBlocksEl = null;
|
||||||
|
/** @type {Cube[]} */ const cubes = [];
|
||||||
|
|
||||||
|
/** @param {HTMLElement} parent the `.chain-scroll` element */
|
||||||
|
export function initMempool(parent) {
|
||||||
|
mempoolBlocksEl = document.createElement("div");
|
||||||
|
mempoolBlocksEl.classList.add("mempool-blocks");
|
||||||
|
mempoolBlocksEl.hidden = true;
|
||||||
|
parent.prepend(mempoolBlocksEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MempoolBlock[]} blocks */
|
||||||
|
export function renderMempool(blocks) {
|
||||||
|
if (!mempoolBlocksEl) return;
|
||||||
|
mempoolBlocksEl.hidden = blocks.length === 0;
|
||||||
|
const want = Math.min(blocks.length, NUM_BLOCKS);
|
||||||
|
while (cubes.length > want) {
|
||||||
|
const last = cubes.pop();
|
||||||
|
if (last) last.el.remove();
|
||||||
|
}
|
||||||
|
while (cubes.length < want) {
|
||||||
|
const cube = createMempoolCube(cubes.length);
|
||||||
|
cubes.push(cube);
|
||||||
|
mempoolBlocksEl.append(cube.el);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < want; i++) updateMempoolCube(cubes[i], blocks[i], i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} position @returns {Cube} */
|
||||||
|
function createMempoolCube(position) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.classList.add("cube", "projected");
|
||||||
|
if (position === 0) el.classList.add("next");
|
||||||
|
const { topFace, rightFace, leftFace } = createCube(el, 0);
|
||||||
|
return { el, topFace, rightFace, leftFace };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Cube} cube
|
||||||
|
* @param {MempoolBlock} block
|
||||||
|
* @param {number} position
|
||||||
|
*/
|
||||||
|
function updateMempoolCube(cube, block, position) {
|
||||||
|
const fill = Math.min(1, block.blockVSize / 1_000_000);
|
||||||
|
cube.el.style.setProperty("--fill", String(fill));
|
||||||
|
|
||||||
|
cube.topFace.textContent = "";
|
||||||
|
const label = document.createElement("p");
|
||||||
|
label.textContent = position === 0 ? "next" : `+${position}`;
|
||||||
|
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");
|
||||||
|
const tilde = document.createElement("span");
|
||||||
|
tilde.classList.add("dim");
|
||||||
|
tilde.textContent = "~";
|
||||||
|
median.append(tilde, formatFeeRate(block.medianFee));
|
||||||
|
const range = document.createElement("p");
|
||||||
|
const dash = document.createElement("span");
|
||||||
|
dash.classList.add("dim");
|
||||||
|
dash.textContent = "-";
|
||||||
|
range.append(formatFeeRate(block.feeRange[0]), dash, formatFeeRate(block.feeRange[6]));
|
||||||
|
const unit = document.createElement("p");
|
||||||
|
unit.classList.add("dim");
|
||||||
|
unit.textContent = "sat/vB";
|
||||||
|
cube.leftFace.append(median, range, unit);
|
||||||
|
}
|
||||||
60
website/src/explorer/chain/cube/index.js
Normal file
60
website/src/explorer/chain/cube/index.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @param {number} [fill]
|
||||||
|
* @returns {{ el: HTMLAnchorElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
|
||||||
|
*/
|
||||||
|
export function createCubeAnchor(fill = 1) {
|
||||||
|
const el = document.createElement("a");
|
||||||
|
return { el, ...populateCube(el, fill) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} [fill]
|
||||||
|
* @returns {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
|
||||||
|
*/
|
||||||
|
export function createCubeDiv(fill = 1) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
return { el, ...populateCube(el, fill) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} el
|
||||||
|
* @param {number} fill
|
||||||
|
*/
|
||||||
|
function populateCube(el, fill) {
|
||||||
|
el.classList.add("cube");
|
||||||
|
el.style.setProperty("--fill", String(fill));
|
||||||
|
|
||||||
|
const topFace = createFace("face-text", "top");
|
||||||
|
const rightFace = createFace("face-text", "right");
|
||||||
|
const leftFace = createFace("face-text", "left");
|
||||||
|
|
||||||
|
el.append(
|
||||||
|
createFace("glass", "bottom"),
|
||||||
|
createFace("glass", "rear-right"),
|
||||||
|
createFace("glass", "rear-left"),
|
||||||
|
createFace("liquid", "bottom"),
|
||||||
|
createFace("liquid", "rear-right"),
|
||||||
|
createFace("liquid", "rear-left"),
|
||||||
|
createFace("liquid", "right"),
|
||||||
|
createFace("liquid", "left"),
|
||||||
|
createFace("liquid", "top"),
|
||||||
|
createFace("glass", "right"),
|
||||||
|
createFace("glass", "left"),
|
||||||
|
createFace("glass", "top"),
|
||||||
|
rightFace,
|
||||||
|
leftFace,
|
||||||
|
topFace,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { topFace, rightFace, leftFace };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} role
|
||||||
|
* @param {string} position
|
||||||
|
* */
|
||||||
|
function createFace(role, position) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `face ${role} ${position}`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
191
website/src/explorer/chain/cube/style.css
Normal file
191
website/src/explorer/chain/cube/style.css
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
:root {
|
||||||
|
--cube-size: 4.5rem;
|
||||||
|
--iso-scale: calc(sqrt(3) / 2);
|
||||||
|
--cube-empty-alpha: 0.4;
|
||||||
|
--cube-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--face-step: 0.033;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cube-pulse {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube {
|
||||||
|
--cube-width: calc(var(--cube-size) * 2 * var(--iso-scale));
|
||||||
|
--cube-height: calc(var(--cube-size) * 2);
|
||||||
|
|
||||||
|
--face-base-light: var(--light-gray);
|
||||||
|
--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(
|
||||||
|
var(--face-base-light),
|
||||||
|
oklch(from var(--face-base-dark) calc(l + var(--face-step) * 2) c h)
|
||||||
|
);
|
||||||
|
--face-right: light-dark(
|
||||||
|
oklch(from var(--face-base-light) calc(l - var(--face-step) * 2) c h),
|
||||||
|
var(--face-base-dark)
|
||||||
|
);
|
||||||
|
--face-left: light-dark(
|
||||||
|
oklch(from var(--face-base-light) calc(l - var(--face-step)) c h),
|
||||||
|
oklch(from var(--face-base-dark) calc(l + var(--face-step)) 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-empty: round(down, calc(1 - var(--fill)), 1);
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: var(--cube-width);
|
||||||
|
height: var(--cube-height);
|
||||||
|
/* .cube can be an <a>; reset anchor styles that would clip the iso
|
||||||
|
silhouette or underline the empty link. */
|
||||||
|
overflow: visible;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color);
|
||||||
|
transition: color var(--cube-ease);
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--background-color);
|
||||||
|
--face-base-light: var(--dark-gray);
|
||||||
|
--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-right: oklch(from var(--face-color-base) calc(l - var(--face-step) * 2) 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.next,
|
||||||
|
&.projected {
|
||||||
|
animation: cube-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visibility (not color:transparent) so child <img> hides too */
|
||||||
|
&.skeleton .face-text {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face {
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: var(--cube-size);
|
||||||
|
height: var(--cube-size);
|
||||||
|
transform:
|
||||||
|
translateY(50%)
|
||||||
|
var(--face-orient)
|
||||||
|
translate(calc(var(--cube-size) * var(--face-x)), calc(var(--cube-size) * var(--face-y)))
|
||||||
|
scale(var(--face-scale-x, 1), var(--face-scale-y));
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* will-change on painted roles only so each gets its own compositor
|
||||||
|
layer for snappy hover/select repaints. */
|
||||||
|
.liquid,
|
||||||
|
.glass {
|
||||||
|
will-change: background-color;
|
||||||
|
transition: background-color var(--cube-ease);
|
||||||
|
}
|
||||||
|
.liquid {
|
||||||
|
background: var(--face-color);
|
||||||
|
opacity: calc(1 - var(--is-empty));
|
||||||
|
--face-scale-y: calc(var(--iso-scale) * var(--fill));
|
||||||
|
--face-stack-shift: calc(var(--iso-scale) * (1 - var(--fill)));
|
||||||
|
}
|
||||||
|
.glass {
|
||||||
|
background: oklch(from var(--face-color) l c h / var(--cube-empty-alpha));
|
||||||
|
--face-scale-y: calc(var(--iso-scale) * (1 - var(--fill)));
|
||||||
|
--face-stack-shift: 0;
|
||||||
|
}
|
||||||
|
.glass.top {
|
||||||
|
opacity: calc(1 - var(--is-full));
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-text {
|
||||||
|
--face-scale-y: var(--iso-scale);
|
||||||
|
--face-stack-shift: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 0.1rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
.face-text.top,
|
||||||
|
.face-text.right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.face-text.top {
|
||||||
|
justify-content: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.face-text.right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.face-text p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top,
|
||||||
|
.bottom {
|
||||||
|
--face-orient: rotate(30deg) skewX(-30deg);
|
||||||
|
--face-scale-y: var(--iso-scale);
|
||||||
|
}
|
||||||
|
.right,
|
||||||
|
.rear-left {
|
||||||
|
--face-orient: rotate(-30deg) skewX(-30deg);
|
||||||
|
}
|
||||||
|
.left,
|
||||||
|
.rear-right {
|
||||||
|
--face-orient: rotate(30deg) skewX(30deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top,
|
||||||
|
.rear-right {
|
||||||
|
--face-y: calc(var(--face-stack-shift) - var(--iso-scale));
|
||||||
|
}
|
||||||
|
.left,
|
||||||
|
.rear-left {
|
||||||
|
--face-y: var(--face-stack-shift);
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
--face-y: calc(var(--face-stack-shift) + var(--iso-scale));
|
||||||
|
}
|
||||||
|
.bottom {
|
||||||
|
--face-y: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top { --face-color: var(--face-top); --face-x: 0; }
|
||||||
|
.bottom { --face-color: var(--face-bottom); --face-x: 1; }
|
||||||
|
.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; }
|
||||||
|
/* Top liquid face slides as fill drops, animating the surface level. */
|
||||||
|
.liquid.top { --face-x: calc(1 - var(--fill)); }
|
||||||
|
}
|
||||||
481
website/src/explorer/chain/index.js
Normal file
481
website/src/explorer/chain/index.js
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import { brk } from "../../../scripts/utils/client.js";
|
||||||
|
import { onPlainClick } from "../../../scripts/utils/dom.js";
|
||||||
|
import {
|
||||||
|
createHeightElement,
|
||||||
|
formatFeeRate,
|
||||||
|
} from "../../../scripts/explorer/render.js";
|
||||||
|
import { createCubeAnchor, createCubeDiv } from "./cube/index.js";
|
||||||
|
|
||||||
|
const LOOKAHEAD = 15;
|
||||||
|
const PROJECTED_LIMIT = 8;
|
||||||
|
const TARGET_BLOCK_SECONDS = 600;
|
||||||
|
const MONTHS = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {HTMLDivElement} */ let chainEl;
|
||||||
|
/** @type {HTMLDivElement} */ let scrollEl;
|
||||||
|
/** @type {HTMLDivElement} */ let confirmedEl;
|
||||||
|
/** @type {HTMLDivElement} */ let projectedEl;
|
||||||
|
/** @type {HTMLAnchorElement | null} */ let selectedCube = null;
|
||||||
|
/** @type {IntersectionObserver} */ let olderEdgeObserver;
|
||||||
|
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
|
||||||
|
/** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {};
|
||||||
|
/** @type {() => void} */ let onTip = () => {};
|
||||||
|
/** @type {() => void} */ let onGenesis = () => {};
|
||||||
|
|
||||||
|
/** @type {Map<BlockHash, BlockInfoV1>} */
|
||||||
|
const blocksByHash = new Map();
|
||||||
|
/** @type {Array<{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }>} */
|
||||||
|
const projectedCubes = [];
|
||||||
|
|
||||||
|
let newestHeight = -1;
|
||||||
|
let oldestHeight = Infinity;
|
||||||
|
let newestTimestamp = 0;
|
||||||
|
let loadingOlder = false;
|
||||||
|
let loadingNewer = false;
|
||||||
|
let reachedTip = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} parent
|
||||||
|
* @param {{
|
||||||
|
* onSelect: (block: BlockInfoV1) => void,
|
||||||
|
* onCubeClick: (cube: HTMLAnchorElement) => void,
|
||||||
|
* onTip: () => void,
|
||||||
|
* onGenesis: () => void,
|
||||||
|
* }} callbacks
|
||||||
|
*/
|
||||||
|
export function initChain(parent, callbacks) {
|
||||||
|
onSelect = callbacks.onSelect;
|
||||||
|
onCubeClick = callbacks.onCubeClick;
|
||||||
|
onTip = callbacks.onTip;
|
||||||
|
onGenesis = callbacks.onGenesis;
|
||||||
|
|
||||||
|
chainEl = document.createElement("div");
|
||||||
|
chainEl.id = "chain";
|
||||||
|
parent.append(chainEl);
|
||||||
|
|
||||||
|
chainEl.append(
|
||||||
|
createEdgeLink("tip", "/block/tip", "Jump to chain tip", onTip),
|
||||||
|
createEdgeLink("gen", "/block/0", "Jump to genesis block", onGenesis),
|
||||||
|
);
|
||||||
|
|
||||||
|
scrollEl = document.createElement("div");
|
||||||
|
scrollEl.classList.add("chain-scroll");
|
||||||
|
chainEl.append(scrollEl);
|
||||||
|
|
||||||
|
projectedEl = document.createElement("div");
|
||||||
|
projectedEl.classList.add("projected");
|
||||||
|
projectedEl.hidden = true;
|
||||||
|
scrollEl.append(projectedEl);
|
||||||
|
|
||||||
|
confirmedEl = document.createElement("div");
|
||||||
|
confirmedEl.classList.add("confirmed");
|
||||||
|
scrollEl.append(confirmedEl);
|
||||||
|
|
||||||
|
olderEdgeObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) loadOlder();
|
||||||
|
},
|
||||||
|
{ root: scrollEl },
|
||||||
|
);
|
||||||
|
|
||||||
|
scrollEl.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
() => {
|
||||||
|
if (reachedTip || loadingNewer) return;
|
||||||
|
if (scrollEl.scrollTop <= 50 && scrollEl.scrollLeft <= 50) loadNewer();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deselectCube() {
|
||||||
|
if (selectedCube) selectedCube.classList.remove("selected");
|
||||||
|
selectedCube = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {HTMLAnchorElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
|
||||||
|
export function selectCube(cube, { scroll, silent } = {}) {
|
||||||
|
if (cube !== selectedCube) {
|
||||||
|
if (selectedCube) selectedCube.classList.remove("selected");
|
||||||
|
selectedCube = cube;
|
||||||
|
cube.classList.add("selected");
|
||||||
|
}
|
||||||
|
if (scroll) {
|
||||||
|
cube.scrollIntoView({
|
||||||
|
behavior: scroll,
|
||||||
|
block: "center",
|
||||||
|
inline: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
const hash = cube.dataset.hash;
|
||||||
|
if (hash) {
|
||||||
|
const block = blocksByHash.get(hash);
|
||||||
|
if (block) onSelect(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {BlockHash | Height | string | null} [hashOrHeight] @param {{ silent?: boolean }} [options] */
|
||||||
|
export async function goToCube(hashOrHeight, { silent } = {}) {
|
||||||
|
if (hashOrHeight === "tip") hashOrHeight = null;
|
||||||
|
if (typeof hashOrHeight === "string" && /^\d+$/.test(hashOrHeight)) {
|
||||||
|
hashOrHeight = Number(hashOrHeight);
|
||||||
|
}
|
||||||
|
let cube = findCube(hashOrHeight);
|
||||||
|
if (cube) {
|
||||||
|
selectCube(cube, { scroll: "smooth", silent });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const cube of confirmedEl.children) cube.classList.add("skeleton");
|
||||||
|
let startHash;
|
||||||
|
try {
|
||||||
|
const height = await resolveHeight(hashOrHeight);
|
||||||
|
startHash = await loadInitial(height);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
startHash = await loadInitial(null);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), {
|
||||||
|
scroll: "instant",
|
||||||
|
silent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function poll() {
|
||||||
|
if (!reachedTip) return;
|
||||||
|
brk
|
||||||
|
.getMempoolBlocks()
|
||||||
|
.then(renderProjected)
|
||||||
|
.catch((e) => console.error("mempool poll:", e));
|
||||||
|
try {
|
||||||
|
const blocks = await brk.getBlocksV1();
|
||||||
|
appendNewerBlocks(blocks);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("explorer poll:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {BlockHash | Height | null} [hashOrHeight] */
|
||||||
|
function findCube(hashOrHeight) {
|
||||||
|
if (hashOrHeight == null) {
|
||||||
|
return reachedTip && newestHeight >= 0
|
||||||
|
? /** @type {HTMLAnchorElement | null} */ (confirmedEl.lastElementChild)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
|
||||||
|
return /** @type {HTMLAnchorElement | null} */ (
|
||||||
|
confirmedEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
newestHeight = -1;
|
||||||
|
oldestHeight = Infinity;
|
||||||
|
newestTimestamp = 0;
|
||||||
|
loadingOlder = false;
|
||||||
|
loadingNewer = false;
|
||||||
|
reachedTip = false;
|
||||||
|
selectedCube = null;
|
||||||
|
confirmedEl.innerHTML = "";
|
||||||
|
olderEdgeObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeOldestEdge() {
|
||||||
|
olderEdgeObserver.disconnect();
|
||||||
|
const oldest = confirmedEl.firstElementChild;
|
||||||
|
if (oldest) olderEdgeObserver.observe(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {BlockInfoV1[]} blocks */
|
||||||
|
function appendNewerBlocks(blocks) {
|
||||||
|
if (!blocks.length) return false;
|
||||||
|
const anchor = confirmedEl.lastElementChild;
|
||||||
|
const anchorRect = anchor?.getBoundingClientRect();
|
||||||
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
|
const b = blocks[i];
|
||||||
|
if (b.height > newestHeight) appendConfirmed(createConfirmedCube(b));
|
||||||
|
else blocksByHash.set(b.id, b);
|
||||||
|
}
|
||||||
|
newestHeight = Math.max(newestHeight, blocks[0].height);
|
||||||
|
newestTimestamp = blocks[0].timestamp;
|
||||||
|
refreshProjectedIntervals();
|
||||||
|
if (anchor && anchorRect) {
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
scrollEl.scrollTop += r.top - anchorRect.top;
|
||||||
|
scrollEl.scrollLeft += r.left - anchorRect.left;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number | null} [height] @returns {Promise<BlockHash>} */
|
||||||
|
async function loadInitial(height) {
|
||||||
|
const blocks =
|
||||||
|
height != null
|
||||||
|
? await brk.getBlocksV1FromHeight(height)
|
||||||
|
: await brk.getBlocksV1();
|
||||||
|
|
||||||
|
clear();
|
||||||
|
for (const b of blocks) prependConfirmed(createConfirmedCube(b));
|
||||||
|
newestHeight = blocks[0].height;
|
||||||
|
oldestHeight = blocks[blocks.length - 1].height;
|
||||||
|
newestTimestamp = blocks[0].timestamp;
|
||||||
|
reachedTip = height == null;
|
||||||
|
observeOldestEdge();
|
||||||
|
|
||||||
|
if (!reachedTip) await loadNewer();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlder() {
|
||||||
|
if (loadingOlder || oldestHeight <= 0) return;
|
||||||
|
loadingOlder = true;
|
||||||
|
try {
|
||||||
|
const blocks = await brk.getBlocksV1FromHeight(oldestHeight - 1);
|
||||||
|
for (const block of blocks) prependConfirmed(createConfirmedCube(block));
|
||||||
|
if (blocks.length) {
|
||||||
|
oldestHeight = blocks[blocks.length - 1].height;
|
||||||
|
observeOldestEdge();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("explorer loadOlder:", e);
|
||||||
|
}
|
||||||
|
loadingOlder = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNewer() {
|
||||||
|
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||||
|
loadingNewer = true;
|
||||||
|
try {
|
||||||
|
const prevNewest = newestHeight;
|
||||||
|
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
||||||
|
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest)
|
||||||
|
reachedTip = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("explorer loadNewer:", e);
|
||||||
|
}
|
||||||
|
loadingNewer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {BlockInfoV1} block */
|
||||||
|
function createConfirmedCube(block) {
|
||||||
|
const { pool, medianFee, feeRange, virtualSize } = block.extras;
|
||||||
|
const fill = Math.min(1, virtualSize / 1_000_000);
|
||||||
|
const { el, topFace, rightFace, leftFace } = createCubeAnchor(fill);
|
||||||
|
el.href = `/block/${block.id}`;
|
||||||
|
el.dataset.hash = block.id;
|
||||||
|
el.dataset.height = String(block.height);
|
||||||
|
el.dataset.timestamp = String(block.timestamp);
|
||||||
|
blocksByHash.set(block.id, block);
|
||||||
|
onPlainClick(el, () => onCubeClick(el));
|
||||||
|
|
||||||
|
const dateP = document.createElement("p");
|
||||||
|
dateP.textContent = formatShortDate(block.timestamp);
|
||||||
|
const [hh, mm] = formatHHMM(block.timestamp);
|
||||||
|
const timeP = document.createElement("p");
|
||||||
|
timeP.append(hh, span(":", "dim"), mm);
|
||||||
|
topFace.append(dateP, timeP);
|
||||||
|
|
||||||
|
const heightP = document.createElement("p");
|
||||||
|
heightP.classList.add("height");
|
||||||
|
heightP.append(createHeightElement(block.height));
|
||||||
|
const poolDiv = document.createElement("div");
|
||||||
|
poolDiv.classList.add("pool");
|
||||||
|
const logo = document.createElement("img");
|
||||||
|
logo.src = `/assets/pools/${poolSlug(pool.name)}.svg`;
|
||||||
|
logo.alt = "";
|
||||||
|
logo.onerror = () => {
|
||||||
|
logo.onerror = null;
|
||||||
|
logo.src = "/assets/pools/default.svg";
|
||||||
|
};
|
||||||
|
const nameSpan = document.createElement("span");
|
||||||
|
nameSpan.textContent = pool.name.replace(/\s+(Pool|USA)$/i, "").trim();
|
||||||
|
poolDiv.append(logo, nameSpan);
|
||||||
|
rightFace.append(heightP, poolDiv);
|
||||||
|
|
||||||
|
const feesEl = document.createElement("div");
|
||||||
|
feesEl.classList.add("fees");
|
||||||
|
const avg = document.createElement("p");
|
||||||
|
avg.append(span("~", "dim"), formatFeeRate(medianFee));
|
||||||
|
const range = document.createElement("p");
|
||||||
|
range.append(
|
||||||
|
formatFeeRate(feeRange[0]),
|
||||||
|
span("-", "dim"),
|
||||||
|
formatFeeRate(feeRange[6]),
|
||||||
|
);
|
||||||
|
const unit = document.createElement("p");
|
||||||
|
unit.classList.add("dim");
|
||||||
|
unit.textContent = "sat/vB";
|
||||||
|
feesEl.append(avg, range, unit);
|
||||||
|
leftFace.append(feesEl);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {HTMLElement} cube */
|
||||||
|
function setConfirmedInterval(cube) {
|
||||||
|
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
|
||||||
|
if (!prev) return;
|
||||||
|
const dt = Math.max(
|
||||||
|
0,
|
||||||
|
Number(cube.dataset.timestamp) - Number(prev.dataset.timestamp),
|
||||||
|
);
|
||||||
|
cube.style.setProperty("--block-interval", String(dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {HTMLAnchorElement} cube */
|
||||||
|
function prependConfirmed(cube) {
|
||||||
|
const next = /** @type {HTMLElement | null} */ (
|
||||||
|
confirmedEl.firstElementChild
|
||||||
|
);
|
||||||
|
confirmedEl.prepend(cube);
|
||||||
|
if (next) setConfirmedInterval(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {HTMLAnchorElement} cube */
|
||||||
|
function appendConfirmed(cube) {
|
||||||
|
confirmedEl.append(cube);
|
||||||
|
setConfirmedInterval(cube);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MempoolBlock[]} blocks */
|
||||||
|
function renderProjected(blocks) {
|
||||||
|
const want = Math.min(blocks.length, PROJECTED_LIMIT);
|
||||||
|
projectedEl.hidden = want === 0;
|
||||||
|
|
||||||
|
while (projectedCubes.length > want) {
|
||||||
|
const last = projectedCubes.pop();
|
||||||
|
if (last) last.el.remove();
|
||||||
|
}
|
||||||
|
while (projectedCubes.length < want) {
|
||||||
|
const cube = createProjectedCube(projectedCubes.length);
|
||||||
|
projectedCubes.push(cube);
|
||||||
|
projectedEl.append(cube.el);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < want; i++)
|
||||||
|
updateProjectedCube(projectedCubes[i], blocks[i], i);
|
||||||
|
refreshProjectedIntervals();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index */
|
||||||
|
function createProjectedCube(index) {
|
||||||
|
const { el, topFace, rightFace, leftFace } = createCubeDiv(0);
|
||||||
|
el.classList.add("projected");
|
||||||
|
if (index === 0) el.classList.add("next");
|
||||||
|
return { el, topFace, rightFace, leftFace };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ el: HTMLDivElement, topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }} cube
|
||||||
|
* @param {MempoolBlock} block
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
function updateProjectedCube(cube, block, index) {
|
||||||
|
const fill = Math.min(1, block.blockVSize / 1_000_000);
|
||||||
|
cube.el.style.setProperty("--fill", String(fill));
|
||||||
|
|
||||||
|
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");
|
||||||
|
unit.classList.add("dim");
|
||||||
|
unit.textContent = "sat/vB";
|
||||||
|
cube.leftFace.append(median, range, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProjectedIntervals() {
|
||||||
|
if (!projectedCubes.length || !newestTimestamp) return;
|
||||||
|
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - newestTimestamp);
|
||||||
|
for (let i = 0; i < projectedCubes.length; i++) {
|
||||||
|
const interval = TARGET_BLOCK_SECONDS * i + elapsed;
|
||||||
|
projectedCubes[i].el.style.setProperty(
|
||||||
|
"--block-interval",
|
||||||
|
String(interval),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {"tip" | "gen"} label @param {string} href @param {string} title @param {() => void} handler */
|
||||||
|
function createEdgeLink(label, href, title, handler) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.classList.add("chain-edge", label);
|
||||||
|
a.href = href;
|
||||||
|
a.title = title;
|
||||||
|
a.textContent = label;
|
||||||
|
onPlainClick(a, handler);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} text @param {string} [cls] */
|
||||||
|
function span(text, cls) {
|
||||||
|
const s = document.createElement("span");
|
||||||
|
if (cls) s.classList.add(cls);
|
||||||
|
s.textContent = text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} name */
|
||||||
|
const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
|
||||||
|
/** @param {number} unixSec */
|
||||||
|
function formatShortDate(unixSec) {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} unixSec */
|
||||||
|
function formatHHMM(unixSec) {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
return [
|
||||||
|
String(d.getHours()).padStart(2, "0"),
|
||||||
|
String(d.getMinutes()).padStart(2, "0"),
|
||||||
|
];
|
||||||
|
}
|
||||||
151
website/src/explorer/chain/style.css
Normal file
151
website/src/explorer/chain/style.css
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#chain {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
--min-gap: calc(var(--cube-size) * -1);
|
||||||
|
--max-gap: calc(var(--cube-size) * 6);
|
||||||
|
--min-block-interval: 0;
|
||||||
|
--max-block-interval: 10800;
|
||||||
|
|
||||||
|
@container aside (max-width: 767px) {
|
||||||
|
--min-gap: 0rem;
|
||||||
|
}
|
||||||
|
@container aside (min-width: 768px) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-scroll {
|
||||||
|
padding: 0 var(--main-padding);
|
||||||
|
|
||||||
|
@container aside (max-width: 767px) {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container aside (min-width: 768px) {
|
||||||
|
padding: var(--main-padding) calc(var(--main-padding) / 2) 6rem
|
||||||
|
var(--main-padding);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projected,
|
||||||
|
.confirmed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
@container aside (max-width: 767px) {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube {
|
||||||
|
--cube-fall-off: pow(
|
||||||
|
clamp(
|
||||||
|
0,
|
||||||
|
(var(--block-interval, 600) - var(--min-block-interval)) /
|
||||||
|
(var(--max-block-interval) - var(--min-block-interval)),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
0.7
|
||||||
|
);
|
||||||
|
--block-gap: calc(
|
||||||
|
var(--min-gap) + var(--cube-fall-off) * (var(--max-gap) - var(--min-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) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: var(--block-gap);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: 50%;
|
||||||
|
left: auto;
|
||||||
|
right: calc(-1 * var(--block-gap));
|
||||||
|
width: var(--block-gap);
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-text .height {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.face-text .fees {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.face-text .pool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1em;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,80 +14,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
--cube: 4.5rem;
|
/*> * {
|
||||||
/* Iso geometry. --iso-scale is the half-width ratio of the hex
|
|
||||||
silhouette (= cos(30deg) = sqrt(3)/2); the silhouette spans
|
|
||||||
2·iso-scale wide and 2 tall in cube-face units. The per-face
|
|
||||||
transforms use the HTML-demo math with ox=oy=0, so the outer
|
|
||||||
compensation on `.face` is just translateY(50%). */
|
|
||||||
--iso-scale: calc(sqrt(3) / 2);
|
|
||||||
--cube-w: calc(var(--cube) * 2 * var(--iso-scale));
|
|
||||||
--cube-h: calc(var(--cube) * 2);
|
|
||||||
--face-step: 0.033;
|
|
||||||
|
|
||||||
/* Cube face-color derivations, resolved once at the #explorer
|
|
||||||
level so .cube state changes (hover / selected) don't force
|
|
||||||
Safari to re-evaluate `oklch(from var …)` on every paint — a
|
|
||||||
known source of jank there. Each interaction state gets its own
|
|
||||||
set; the .cube rule below just swaps which set --face-right /
|
|
||||||
--face-left / --face-top / --face-bottom reference. */
|
|
||||||
--cube-neutral-right: light-dark(
|
|
||||||
oklch(from var(--light-gray) calc(l - var(--face-step) * 2) c h),
|
|
||||||
var(--dark-gray)
|
|
||||||
);
|
|
||||||
--cube-neutral-left: light-dark(
|
|
||||||
oklch(from var(--light-gray) calc(l - var(--face-step)) c h),
|
|
||||||
oklch(from var(--dark-gray) calc(l + var(--face-step)) c h)
|
|
||||||
);
|
|
||||||
--cube-neutral-top: light-dark(
|
|
||||||
var(--light-gray),
|
|
||||||
oklch(from var(--dark-gray) calc(l + var(--face-step) * 2) c h)
|
|
||||||
);
|
|
||||||
--cube-neutral-bottom: oklch(
|
|
||||||
from var(--border-color) calc(l - var(--face-step) * 3) c h
|
|
||||||
);
|
|
||||||
|
|
||||||
--cube-hover-right: light-dark(
|
|
||||||
oklch(from var(--dark-gray) calc(l - var(--face-step) * 2) c h),
|
|
||||||
var(--light-gray)
|
|
||||||
);
|
|
||||||
--cube-hover-left: light-dark(
|
|
||||||
oklch(from var(--dark-gray) calc(l - var(--face-step)) c h),
|
|
||||||
oklch(from var(--light-gray) calc(l + var(--face-step)) c h)
|
|
||||||
);
|
|
||||||
--cube-hover-top: light-dark(
|
|
||||||
var(--dark-gray),
|
|
||||||
oklch(from var(--light-gray) calc(l + var(--face-step) * 2) c h)
|
|
||||||
);
|
|
||||||
--cube-hover-bottom: oklch(
|
|
||||||
from var(--inv-border-color) calc(l - var(--face-step) * 3) c h
|
|
||||||
);
|
|
||||||
|
|
||||||
--cube-selected-right: light-dark(
|
|
||||||
oklch(from var(--orange) calc(l - var(--face-step) * 2) c h),
|
|
||||||
var(--orange)
|
|
||||||
);
|
|
||||||
--cube-selected-left: light-dark(
|
|
||||||
oklch(from var(--orange) calc(l - var(--face-step)) c h),
|
|
||||||
oklch(from var(--orange) calc(l + var(--face-step)) c h)
|
|
||||||
);
|
|
||||||
--cube-selected-top: light-dark(
|
|
||||||
var(--orange),
|
|
||||||
oklch(from var(--orange) calc(l + var(--face-step) * 2) c h)
|
|
||||||
);
|
|
||||||
--cube-selected-bottom: oklch(
|
|
||||||
from var(--orange) calc(l - var(--face-step) * 3) c h
|
|
||||||
);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
padding: 0 var(--main-padding);
|
padding: 0 var(--main-padding);
|
||||||
|
|
||||||
@container aside (min-width: 768px) {
|
@container aside (min-width: 768px) {
|
||||||
padding: var(--main-padding);
|
padding: var(--main-padding);
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
#chain {
|
/*#chain {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -141,13 +76,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
@container aside (min-width: 768px) { top: 0; }
|
@container aside (min-width: 768px) {
|
||||||
@container aside (max-width: 767px) { left: 0; }
|
top: 0;
|
||||||
|
}
|
||||||
|
@container aside (max-width: 767px) {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen {
|
.gen {
|
||||||
@container aside (min-width: 768px) { bottom: 0; }
|
@container aside (min-width: 768px) {
|
||||||
@container aside (max-width: 767px) { top: 0; right: 0; }
|
bottom: 0;
|
||||||
|
}
|
||||||
|
@container aside (max-width: 767px) {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blocks {
|
.blocks {
|
||||||
@@ -183,18 +127,11 @@
|
|||||||
);
|
);
|
||||||
--empty-alpha: 0.4;
|
--empty-alpha: 0.4;
|
||||||
|
|
||||||
/* Face colors reference the precomputed sets on #explorer
|
|
||||||
(see top of file). Hover / selected rules just switch which
|
|
||||||
set each --face-* points at. */
|
|
||||||
--face-right: var(--cube-neutral-right);
|
--face-right: var(--cube-neutral-right);
|
||||||
--face-left: var(--cube-neutral-left);
|
--face-left: var(--cube-neutral-left);
|
||||||
--face-top: var(--cube-neutral-top);
|
--face-top: var(--cube-neutral-top);
|
||||||
--face-bottom: var(--cube-neutral-bottom);
|
--face-bottom: var(--cube-neutral-bottom);
|
||||||
|
|
||||||
/* Fill-driven state. --liquid-y is the liquid's vertical scale;
|
|
||||||
--glass-y the glass-above-liquid's. --is-full / --is-empty
|
|
||||||
are 1 only at the exact endpoints (fill == 1 / fill == 0)
|
|
||||||
and drive opacity so those roles hide cleanly there. */
|
|
||||||
--liquid-y: calc(var(--iso-scale) * var(--fill));
|
--liquid-y: calc(var(--iso-scale) * var(--fill));
|
||||||
--glass-y: calc(var(--iso-scale) * (1 - var(--fill)));
|
--glass-y: calc(var(--iso-scale) * (1 - var(--fill)));
|
||||||
--is-full: round(down, var(--fill), 1);
|
--is-full: round(down, var(--fill), 1);
|
||||||
@@ -205,9 +142,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: var(--cube-w);
|
width: var(--cube-w);
|
||||||
height: var(--cube-h);
|
height: var(--cube-h);
|
||||||
/* .cube is an <a>; reset the global anchor styles in
|
|
||||||
elements.css that would clip the iso silhouette
|
|
||||||
(overflow:hidden) and underline the empty link. */
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
--state-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1);
|
--state-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -233,40 +167,22 @@
|
|||||||
--face-bottom: var(--cube-selected-bottom);
|
--face-bottom: var(--cube-selected-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton state (cube painted but data is stale while a new
|
|
||||||
chunk loads): hide text AND the pool logo. Using visibility
|
|
||||||
rather than color:transparent so the raw <img> logo hides too. */
|
|
||||||
&.skeleton .face-text {
|
&.skeleton .face-text {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared face-transform template. Each face div sets --orient,
|
|
||||||
--x, --y, --sx, --sy and its role (liquid/glass/face-text)
|
|
||||||
supplies --y-offset. Faces extend outside .cube's layout box
|
|
||||||
— the iso silhouette spans ~2·iso·cube × 2·cube, offset into
|
|
||||||
what would be the next cube's space. Clicks land only on the
|
|
||||||
transformed face rectangles, not the .cube's empty corners. */
|
|
||||||
.face {
|
.face {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: var(--cube);
|
width: var(--cube);
|
||||||
height: var(--cube);
|
height: var(--cube);
|
||||||
transform:
|
transform: translateY(50%) var(--orient)
|
||||||
translateY(50%)
|
|
||||||
var(--orient)
|
|
||||||
translate(calc(var(--cube) * var(--x)), calc(var(--cube) * var(--y)))
|
translate(calc(var(--cube) * var(--x)), calc(var(--cube) * var(--y)))
|
||||||
scale(var(--sx, 1), var(--sy));
|
scale(var(--sx, 1), var(--sy));
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Roles:
|
|
||||||
.liquid opaque liquid (scales with fill)
|
|
||||||
.glass translucent glass shell
|
|
||||||
.face-text text overlay spanning the full rhombus
|
|
||||||
will-change is on the painted roles only (not .face-text,
|
|
||||||
whose background never changes) so each liquid/glass gets its
|
|
||||||
own compositor layer for snappy hover/select repaints. */
|
|
||||||
.liquid,
|
.liquid,
|
||||||
.glass {
|
.glass {
|
||||||
will-change: background-color;
|
will-change: background-color;
|
||||||
@@ -324,7 +240,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
/* Pool line: raw (un-tinted) logo + miner name, ellipsis-clipped. */
|
|
||||||
.face-text .pool {
|
.face-text .pool {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -345,8 +260,6 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-face geometry. 3 orientations × 4 vertical anchors. Each
|
|
||||||
face picks one of each (plus its color/horizontal anchor). */
|
|
||||||
.top,
|
.top,
|
||||||
.bottom {
|
.bottom {
|
||||||
--orient: rotate(30deg) skewX(-30deg);
|
--orient: rotate(30deg) skewX(-30deg);
|
||||||
@@ -361,9 +274,6 @@
|
|||||||
--orient: rotate(30deg) skewX(30deg);
|
--orient: rotate(30deg) skewX(30deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vertical anchors (cube-face units from the layout origin).
|
|
||||||
--y-offset is the role-specific fill shift (liquid sides
|
|
||||||
get glass-y, everything else 0). */
|
|
||||||
.top,
|
.top,
|
||||||
.rear-right {
|
.rear-right {
|
||||||
--y: calc(var(--y-offset) - var(--iso-scale));
|
--y: calc(var(--y-offset) - var(--iso-scale));
|
||||||
@@ -379,14 +289,34 @@
|
|||||||
--y: 0;
|
--y: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Per-face color + horizontal anchor. */
|
.top {
|
||||||
.top { --fc: var(--face-top); --x: var(--top-x-shift, 0); }
|
--fc: var(--face-top);
|
||||||
.bottom { --fc: var(--face-bottom); --x: 1; }
|
--x: var(--top-x-shift, 0);
|
||||||
.right { --fc: var(--face-right); --x: 1; }
|
}
|
||||||
.left { --fc: var(--face-left); --x: 0; }
|
.bottom {
|
||||||
.rear-right { --fc: var(--face-left); --x: 1; }
|
--fc: var(--face-bottom);
|
||||||
.rear-left { --fc: var(--face-top); --x: 1; --sx: -1; }
|
--x: 1;
|
||||||
.liquid.top { --top-x-shift: calc(1 - var(--fill)); }
|
}
|
||||||
|
.right {
|
||||||
|
--fc: var(--face-right);
|
||||||
|
--x: 1;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
--fc: var(--face-left);
|
||||||
|
--x: 0;
|
||||||
|
}
|
||||||
|
.rear-right {
|
||||||
|
--fc: var(--face-left);
|
||||||
|
--x: 1;
|
||||||
|
}
|
||||||
|
.rear-left {
|
||||||
|
--fc: var(--face-top);
|
||||||
|
--x: 1;
|
||||||
|
--sx: -1;
|
||||||
|
}
|
||||||
|
.liquid.top {
|
||||||
|
--top-x-shift: calc(1 - var(--fill));
|
||||||
|
}
|
||||||
|
|
||||||
& + & {
|
& + & {
|
||||||
margin-bottom: var(--block-gap);
|
margin-bottom: var(--block-gap);
|
||||||
@@ -417,7 +347,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
#block-details,
|
#block-details,
|
||||||
#tx-details,
|
#tx-details,
|
||||||
|
|||||||
Reference in New Issue
Block a user