global: snap

This commit is contained in:
nym21
2026-04-18 17:23:12 +02:00
parent 2a93f51e81
commit fd2b93367d
31 changed files with 1004 additions and 988 deletions
+89 -65
View File
@@ -1,14 +1,15 @@
import { brk } from "../utils/client.js";
import { createCube } from "./cube.js";
import { createHeightElement, formatFeeRate } from "./render.js";
const LOOKAHEAD = 15;
/** @type {HTMLDivElement} */ let chainEl;
/** @type {HTMLDivElement} */ let blocksEl;
/** @type {HTMLDivElement | null} */ let selectedCube = null;
/** @type {HTMLAnchorElement | null} */ let selectedCube = null;
/** @type {IntersectionObserver} */ let olderObserver;
/** @type {(block: BlockInfoV1) => void} */ let onSelect = () => {};
/** @type {(cube: HTMLDivElement) => void} */ let onCubeClick = () => {};
/** @type {(cube: HTMLAnchorElement) => void} */ let onCubeClick = () => {};
/** @type {Map<BlockHash, BlockInfoV1>} */
const blocksByHash = new Map();
@@ -21,7 +22,7 @@ let reachedTip = false;
/**
* @param {HTMLElement} parent
* @param {{ onSelect: (block: BlockInfoV1) => void, onCubeClick: (cube: HTMLDivElement) => void }} callbacks
* @param {{ onSelect: (block: BlockInfoV1) => void, onCubeClick: (cube: HTMLAnchorElement) => void }} callbacks
*/
export function initChain(parent, callbacks) {
onSelect = callbacks.onSelect;
@@ -60,11 +61,11 @@ export function initChain(parent, callbacks) {
function findCube(hashOrHeight) {
if (hashOrHeight == null) {
return reachedTip && newestHeight >= 0
? /** @type {HTMLDivElement | null} */ (blocksEl.lastElementChild)
? /** @type {HTMLAnchorElement | null} */ (blocksEl.lastElementChild)
: null;
}
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLDivElement | null} */ (
return /** @type {HTMLAnchorElement | null} */ (
blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
);
}
@@ -74,7 +75,7 @@ export function deselectCube() {
selectedCube = null;
}
/** @param {HTMLDivElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
/** @param {HTMLAnchorElement} cube @param {{ scroll?: "smooth" | "instant", silent?: boolean }} [opts] */
export function selectCube(cube, { scroll, silent } = {}) {
const changed = cube !== selectedCube;
if (changed) {
@@ -181,7 +182,7 @@ export async function goToCube(hashOrHeight, { silent } = {}) {
} catch (e) {
try { startHash = await loadInitial(null); } catch (_) { return; }
}
selectCube(/** @type {HTMLDivElement} */ (findCube(startHash)), { scroll: "instant", silent });
selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), { scroll: "instant", silent });
}
export async function poll() {
@@ -223,80 +224,103 @@ async function loadNewer() {
loadingNewer = false;
}
/** @param {string} name */
const poolSlug = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
/** @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")];
}
/** @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 {BlockInfoV1} block */
function createBlockCube(block) {
const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
createCube();
const cubeElement = document.createElement("a");
cubeElement.classList.add("cube");
cubeElement.href = `/block/${block.id}`;
cubeElement.dataset.hash = block.id;
cubeElement.dataset.height = String(block.height);
cubeElement.dataset.timestamp = String(block.timestamp);
cubeElement.style.setProperty("--fill", String(Math.min(1, block.weight / 3_990_000)));
const fill = Math.min(1, block.weight / 3_990_000);
const { topFace, rightFace, leftFace } = createCube(cubeElement, fill);
blocksByHash.set(block.id, block);
cubeElement.addEventListener("click", () => onCubeClick(cubeElement));
// Intercept plain left-clicks for SPA nav; let modified clicks
// (cmd/ctrl/shift/middle) and right-click fall through so the
// anchor's native open-in-new-tab / context-menu behavior works.
cubeElement.addEventListener("click", (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onCubeClick(cubeElement);
});
const heightEl = document.createElement("p");
heightEl.append(createHeightElement(block.height));
rightFaceElement.append(heightEl);
const extras = block.extras;
const minerName = extras ? extras.pool.name : "Unknown";
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
// Top: short date / HH:MM (colon dimmed).
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);
// Right: block height / raw pool-logo + miner name.
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(minerName)}.svg`;
logo.alt = "";
logo.onerror = () => {
logo.onerror = null;
logo.src = "/assets/pools/default.svg";
};
const nameSpan = document.createElement("span");
nameSpan.textContent = minerName.replace(/\s+(Pool|USA)$/i, "").trim();
poolDiv.append(logo, nameSpan);
rightFace.append(heightP, poolDiv);
// Left: ~median / min-max / sat/vB fees stack.
const feesEl = document.createElement("div");
feesEl.classList.add("fees");
leftFaceElement.append(feesEl);
const extras = block.extras;
const medianFee = extras ? extras.medianFee : 0;
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
const avg = document.createElement("p");
avg.innerHTML = `~${formatFeeRate(medianFee)}`;
feesEl.append(avg);
avg.textContent = `~${formatFeeRate(medianFee)}`;
const range = document.createElement("p");
const min = document.createElement("span");
min.innerHTML = formatFeeRate(feeRange[0]);
const dash = document.createElement("span");
dash.classList.add("dim");
dash.innerHTML = `-`;
const max = document.createElement("span");
max.innerHTML = formatFeeRate(feeRange[6]);
range.append(min, dash, max);
feesEl.append(range);
range.append(
formatFeeRate(feeRange[0]),
span("-", "dim"),
formatFeeRate(feeRange[6]),
);
const unit = document.createElement("p");
unit.classList.add("dim");
unit.innerHTML = `sat/vB`;
feesEl.append(unit);
const miner = document.createElement("span");
miner.innerHTML = extras ? extras.pool.name : "Unknown";
topFaceElement.append(miner);
unit.textContent = "sat/vB";
feesEl.append(avg, range, unit);
leftFace.append(feesEl);
return cubeElement;
}
function createCube() {
const cubeElement = document.createElement("div");
cubeElement.classList.add("cube");
const bottomElement = document.createElement("div");
bottomElement.classList.add("face", "bottom");
cubeElement.append(bottomElement);
const rearRightElement = document.createElement("div");
rearRightElement.classList.add("face", "rear-right");
cubeElement.append(rearRightElement);
const rearLeftElement = document.createElement("div");
rearLeftElement.classList.add("face", "rear-left");
cubeElement.append(rearLeftElement);
const innerTopElement = document.createElement("div");
innerTopElement.classList.add("face", "inner-top");
cubeElement.append(innerTopElement);
const rightFaceElement = document.createElement("div");
rightFaceElement.classList.add("face", "right");
cubeElement.append(rightFaceElement);
const leftFaceElement = document.createElement("div");
leftFaceElement.classList.add("face", "left");
cubeElement.append(leftFaceElement);
const topFaceElement = document.createElement("div");
topFaceElement.classList.add("face", "top");
cubeElement.append(topFaceElement);
return { cubeElement, leftFaceElement, rightFaceElement, topFaceElement };
}
/** @param {HTMLElement} cube */
function setGap(cube) {
const prev = /** @type {HTMLElement | null} */ (cube.previousElementSibling);
@@ -305,14 +329,14 @@ function setGap(cube) {
cube.style.setProperty("--dt", String(dt));
}
/** @param {HTMLDivElement} cube */
/** @param {HTMLAnchorElement} cube */
function prependCube(cube) {
const next = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild);
blocksEl.prepend(cube);
if (next) setGap(next);
}
/** @param {HTMLDivElement} cube */
/** @param {HTMLAnchorElement} cube */
function appendCube(cube) {
blocksEl.append(cube);
setGap(cube);
+51
View File
@@ -0,0 +1,51 @@
/**
* HTML cube generator. Populates a .cube element with 15 face divs
* styled in explorer.css. Uses pure CSS transforms (no SVG); the
* earlier SVG-based implementation broke in Safari due to its
* long-standing bugs around SVG transforms on <foreignObject>.
*
* Face order = z-order:
* 3× .glass rear — translucent glass back faces
* 3× .liquid rear — opaque liquid backing (hidden at fill 0)
* 3× .liquid front — opaque liquid front (the visible 3 faces)
* 3× .glass front — translucent glass front
* 3× .face-text — text overlays (top / right / left)
*
* @param {HTMLElement} cube
* @param {number} [fill]
* @returns {{ topFace: HTMLDivElement, rightFace: HTMLDivElement, leftFace: HTMLDivElement }}
*/
export function createCube(cube, fill = 1) {
cube.style.setProperty("--fill", String(fill));
/** @param {...string} cls */
const face = (...cls) => {
const d = document.createElement("div");
d.className = `face ${cls.join(" ")}`;
return /** @type {HTMLDivElement} */ (d);
};
const topFace = face("face-text", "top");
const rightFace = face("face-text", "right");
const leftFace = face("face-text", "left");
cube.append(
face("glass", "bottom"),
face("glass", "rear-right"),
face("glass", "rear-left"),
face("liquid", "bottom"),
face("liquid", "rear-right"),
face("liquid", "rear-left"),
face("liquid", "right"),
face("liquid", "left"),
face("liquid", "top"),
face("glass", "right"),
face("glass", "left"),
face("glass", "top"),
rightFace,
leftFace,
topFace,
);
return { topFace, rightFace, leftFace };
}
+1 -2
View File
@@ -78,8 +78,7 @@ export function init(selected) {
showPanel("block");
},
onCubeClick: (cube) => {
const hash = cube.dataset.hash;
if (hash) history.pushState(null, "", `/block/${hash}`);
history.pushState(null, "", cube.href);
navigate();
selectCube(cube);
},
@@ -2,9 +2,9 @@
* Holdings section builders
*
* Supply pattern capabilities by cohort type:
* - DeltaHalfInRelTotalPattern2 (STH/LTH): inProfit + inLoss + toCirculating + toOwn
* - SeriesTree_Cohorts_Utxo_All_Supply (All): inProfit + inLoss + toOwn (no toCirculating)
* - DeltaHalfInRelTotalPattern (AgeRange/MaxAge/Epoch): inProfit + inLoss + toCirculating (no toOwn)
* - DeltaHalfInRelTotalPattern2 (STH/LTH): inProfit + inLoss + dominance + share
* - SeriesTree_Cohorts_Utxo_All_Supply (All): inProfit + inLoss + share (no dominance)
* - DeltaHalfInRelTotalPattern (AgeRange/MaxAge/Epoch): inProfit + inLoss + dominance (no share)
* - DeltaHalfInTotalPattern2 (Type.*): inProfit + inLoss (no rel)
* - DeltaHalfTotalPattern (Empty/UtxoAmount/AddrAmount): total + half only
*/
@@ -184,18 +184,18 @@ function profitabilityAmountChart(supply, title) {
}
/**
* Share chart: in profit / in loss as % of own supply.
* @param {{ inProfit: { toOwn: { percent: AnySeriesPattern, ratio: AnySeriesPattern } }, inLoss: { toOwn: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} supply
* Composition chart: in profit / in loss as % of own supply.
* @param {{ inProfit: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } }, inLoss: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} supply
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function profitabilityShareChart(supply, title) {
function profitabilityCompositionChart(supply, title) {
return {
name: "Share",
title: title("Supply Profitability"),
name: "Composition",
title: title("Supply Profitability Composition"),
bottom: [
...percentRatio({ pattern: supply.inProfit.toOwn, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: supply.inLoss.toOwn, name: "In Loss", color: colors.loss }),
...percentRatio({ pattern: supply.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: supply.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],
@@ -204,19 +204,16 @@ function profitabilityShareChart(supply, title) {
/**
* @param {{ toCirculating: PercentRatioPattern, inProfit: { toCirculating: PercentRatioPattern }, inLoss: { toCirculating: PercentRatioPattern } }} supply
* @param {{ dominance: PercentRatioPattern }} supply
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function circulatingChart(supply, title) {
function dominanceChart(supply, color, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: [
...percentRatio({ pattern: supply.toCirculating, name: "Total", color: colors.default }),
...percentRatio({ pattern: supply.inProfit.toCirculating, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: supply.inLoss.toCirculating, name: "In Loss", color: colors.loss }),
],
bottom: percentRatio({ pattern: supply.dominance, name: "Dominance", color }),
};
}
@@ -294,6 +291,7 @@ export function createHoldingsSection({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleDeltaItems(supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -320,7 +318,7 @@ export function createHoldingsSectionAll({ cohort, title }) {
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityShareChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleDeltaItems(supply.delta, Unit.sats, title, "Supply"),
@@ -346,12 +344,12 @@ export function createHoldingsSectionWithRelative({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityShareChart(supply, title),
circulatingChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleDeltaItems(supply.delta, Unit.sats, title, "Supply"),
@@ -376,12 +374,10 @@ export function createHoldingsSectionWithOwnSupply({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
circulatingChart(supply, title),
],
tree: [profitabilityAmountChart(supply, title)],
},
...singleDeltaItems(supply.delta, Unit.sats, title, "Supply"),
],
@@ -405,6 +401,7 @@ export function createHoldingsSectionWithProfitLoss({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
@@ -431,6 +428,7 @@ export function createHoldingsSectionAddress({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
@@ -458,6 +456,7 @@ export function createHoldingsSectionAddressAmount({ cohort, title }) {
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleDeltaItems(supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -495,6 +494,22 @@ function groupedSupplyProfitLoss(list, all, title) {
];
}
/**
* @template {{ name: string, color: Color, tree: { supply: { dominance: PercentRatioPattern } } }} T
* @param {readonly T[]} list
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function groupedDominanceChart(list, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: flatMapCohorts(list, ({ name, color, tree }) =>
percentRatio({ pattern: tree.supply.dominance, name, color }),
),
};
}
// ============================================================================
// Grouped Cohort Holdings Sections
// ============================================================================
@@ -509,6 +524,7 @@ export function createGroupedHoldingsSectionAddress({ list, all, title }) {
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
@@ -563,6 +579,7 @@ export function createGroupedHoldingsSectionAddressAmount({ list, all, title })
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedDeltaItems(list, all, (c) => c.tree.supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -590,6 +607,7 @@ export function createGroupedHoldingsSection({ list, all, title }) {
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedDeltaItems(list, all, (c) => c.tree.supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -604,7 +622,11 @@ export function createGroupedHoldingsSectionWithProfitLoss({ list, all, title })
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
...groupedSupplyProfitLoss(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedDeltaItems(list, all, (c) => c.tree.supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -619,8 +641,11 @@ export function createGroupedHoldingsSectionWithOwnSupply({ list, all, title })
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
...groupedSupplyProfitLoss(list, all, title),
{ name: "% of Circulating", title: title("Supply (% of Circulating)"), bottom: flatMapCohorts(list, ({ name, color, tree }) => percentRatio({ pattern: tree.supply.toCirculating, name, color })) },
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedDeltaItems(list, all, (c) => c.tree.supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -629,7 +654,7 @@ export function createGroupedHoldingsSectionWithOwnSupply({ list, all, title })
}
/**
* Grouped holdings with full relative series (toCirculating + toOwn)
* Grouped holdings with full relative series (dominance + share)
* For: CohortFull, CohortLongTerm
* @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
@@ -640,9 +665,20 @@ export function createGroupedHoldingsSectionWithRelative({ list, all, title }) {
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
...groupedSupplyProfitLoss(list, all, title),
{ name: "% of Circulating", title: title("Supply (% of Circulating)"), bottom: flatMapCohorts(list, ({ name, color, tree }) => percentRatio({ pattern: tree.supply.toCirculating, name, color })) },
{ name: "% of Own Supply", title: title("Supply (% of Own)"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inProfit.toOwn.percent, name, color, unit: Unit.percentage })) },
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: [
...groupedSupplyProfitLoss(list, all, title),
{
name: "Composition",
tree: [
{ name: "In Profit", title: title("Supply In Profit Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inProfit.share.percent, name, color, unit: Unit.percentage })) },
{ name: "In Loss", title: title("Supply In Loss Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inLoss.share.percent, name, color, unit: Unit.percentage })) },
],
},
],
},
...groupedDeltaItems(list, all, (c) => c.tree.supply.delta, Unit.sats, title, "Supply"),
],
},
@@ -92,11 +92,11 @@ export function createValuationSectionFull({ cohort, title }) {
],
},
{
name: "Share",
title: title("Invested Capital Profitability"),
name: "Composition",
title: title("Invested Capital Composition"),
bottom: [
...percentRatio({ pattern: tree.investedCapital.inProfit.toOwn, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: tree.investedCapital.inLoss.toOwn, name: "In Loss", color: colors.loss }),
...percentRatio({ pattern: tree.investedCapital.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: tree.investedCapital.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],