mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-29 19:58:10 -07:00
global: snap
This commit is contained in:
@@ -0,0 +1 @@
|
||||
3d.html
|
||||
+275
-112
File diff suppressed because one or more lines are too long
+76
-147
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }),
|
||||
],
|
||||
|
||||
@@ -256,7 +256,7 @@ summary {
|
||||
&,
|
||||
*,
|
||||
&::after {
|
||||
color: var(--color) !important;
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ summary {
|
||||
&,
|
||||
*,
|
||||
&::after {
|
||||
color: var(--orange) !important;
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+184
-135
@@ -15,6 +15,28 @@
|
||||
}
|
||||
|
||||
--cube: 4.5rem;
|
||||
--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);
|
||||
@@ -48,7 +70,6 @@
|
||||
margin-top: calc(var(--cube) * -0.25);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
--min-gap: 0rem;
|
||||
--max-gap: calc(var(--cube) * 1.5);
|
||||
flex-direction: row-reverse;
|
||||
height: 11.5rem;
|
||||
@@ -72,178 +93,206 @@
|
||||
--block-gap: calc(
|
||||
var(--min-gap) + var(--t) * (var(--max-gap) - var(--min-gap))
|
||||
);
|
||||
--empty-alpha: 0.3;
|
||||
--iso-scale: 0.866;
|
||||
/* Iso projection constants. Changing these reshapes the whole
|
||||
cube — they drive the per-face transforms below. */
|
||||
--iso-scale: cos(30deg);
|
||||
--ox: 0.3;
|
||||
--oy: 0.6;
|
||||
--fill-pct: calc(var(--fill, 1) * 100%);
|
||||
--face-step: 0.033;
|
||||
--face-right-color: light-dark(
|
||||
oklch(from var(--face-color) calc(l - var(--face-step) * 2) c h),
|
||||
var(--face-color)
|
||||
);
|
||||
--face-left-color: light-dark(
|
||||
oklch(from var(--face-color) calc(l - var(--face-step)) c h),
|
||||
oklch(from var(--face-color) calc(l + var(--face-step)) c h)
|
||||
);
|
||||
--face-top-color: light-dark(
|
||||
var(--face-color),
|
||||
oklch(from var(--face-color) calc(l + var(--face-step) * 2) c h)
|
||||
);
|
||||
--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-left: var(--cube-neutral-left);
|
||||
--face-top: var(--cube-neutral-top);
|
||||
--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
|
||||
round to 0 or 1 and drive opacity so scaled-to-0 faces
|
||||
vanish cleanly (no hairline AA seams). */
|
||||
--liquid-y: calc(var(--iso-scale) * var(--fill));
|
||||
--glass-y: calc(var(--iso-scale) * (1 - var(--fill)));
|
||||
--is-full: round(down, calc(var(--fill) + 0.0025), 1);
|
||||
--is-empty: round(down, calc(1.0025 - var(--fill)), 1);
|
||||
|
||||
margin-left: calc(var(--cube) * -0.25);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: var(--cube);
|
||||
height: var(--cube);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
--face-color: var(--border-color);
|
||||
/* .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;
|
||||
text-decoration: none;
|
||||
--state-ease: 50ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--color);
|
||||
transition-property: color, background-color;
|
||||
transition-duration: 50ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: color var(--state-ease);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
--face-color: var(--inv-border-color);
|
||||
color: var(--background-color);
|
||||
--face-right: var(--cube-hover-right);
|
||||
--face-left: var(--cube-hover-left);
|
||||
--face-top: var(--cube-hover-top);
|
||||
--face-bottom: var(--cube-hover-bottom);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.selected {
|
||||
color: var(--black);
|
||||
--face-color: var(--orange);
|
||||
--face-right: var(--cube-selected-right);
|
||||
--face-left: var(--cube-selected-left);
|
||||
--face-top: var(--cube-selected-top);
|
||||
--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 { 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 {
|
||||
position: absolute;
|
||||
transform-origin: 0 0;
|
||||
box-sizing: border-box;
|
||||
width: var(--cube);
|
||||
height: var(--cube);
|
||||
transform: var(--orient)
|
||||
translate(calc(var(--cube) * var(--x)), calc(var(--cube) * var(--y)))
|
||||
scale(var(--sx, 1), var(--sy));
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.face {
|
||||
transform-origin: 0 0;
|
||||
position: absolute;
|
||||
width: var(--cube);
|
||||
height: var(--cube);
|
||||
/* 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, .glass {
|
||||
will-change: background-color;
|
||||
transition: background-color var(--state-ease);
|
||||
}
|
||||
.liquid {
|
||||
background: var(--fc);
|
||||
opacity: calc(1 - var(--is-empty));
|
||||
--sy: var(--liquid-y);
|
||||
--y-offset: var(--glass-y);
|
||||
}
|
||||
.glass {
|
||||
background: oklch(from var(--fc) l c h / var(--empty-alpha));
|
||||
--sy: var(--glass-y);
|
||||
--y-offset: 0;
|
||||
}
|
||||
.glass.top { opacity: calc(1 - var(--is-full)); }
|
||||
|
||||
.right,
|
||||
.left,
|
||||
.top {
|
||||
.face-text {
|
||||
--sy: var(--iso-scale);
|
||||
--y-offset: 0;
|
||||
pointer-events: none;
|
||||
padding: 0.1rem;
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.right,
|
||||
.left,
|
||||
.rear-right,
|
||||
.rear-left {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--fc) var(--fill-pct),
|
||||
oklch(from var(--fc) l c h / var(--empty-alpha)) var(--fill-pct)
|
||||
);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
background-color: var(--face-color);
|
||||
transform: rotate(30deg) skew(-30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + 1 + var(--oy) / var(--iso-scale))),
|
||||
calc(var(--cube) * var(--oy))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
}
|
||||
|
||||
.rear-right {
|
||||
--fc: var(--face-left-color);
|
||||
transform: rotate(30deg) skewX(30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + 1)),
|
||||
calc(var(--cube) * (var(--oy) - var(--iso-scale)))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
}
|
||||
|
||||
.rear-left {
|
||||
--fc: var(--face-top-color);
|
||||
transform: rotate(-30deg) skewX(-30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + 1)),
|
||||
calc(var(--cube) * (var(--ox) * var(--iso-scale) + var(--oy)))
|
||||
)
|
||||
scale(-1, var(--iso-scale));
|
||||
}
|
||||
|
||||
.inner-top {
|
||||
background-color: var(--face-top-color);
|
||||
transform: rotate(30deg) skew(-30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + 1 + var(--oy) / var(--iso-scale) - var(--fill, 1))),
|
||||
calc(var(--cube) * (var(--oy) - var(--iso-scale) * var(--fill, 1)))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
}
|
||||
|
||||
.right {
|
||||
--fc: var(--face-right-color);
|
||||
transform: rotate(-30deg) skewX(-30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + 1)),
|
||||
calc(var(--cube) * ((var(--ox) + 1) * var(--iso-scale) + var(--oy)))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
}
|
||||
|
||||
.top {
|
||||
--is-full: round(down, calc(var(--fill, 1) + 0.0025), 1);
|
||||
background-color: oklch(
|
||||
from var(--face-top-color) l c h /
|
||||
calc(var(--empty-alpha) + var(--is-full) * (1 - var(--empty-alpha)))
|
||||
);
|
||||
transform: rotate(30deg) skew(-30deg)
|
||||
translate(
|
||||
calc(var(--cube) * (var(--ox) + var(--oy) / var(--iso-scale))),
|
||||
calc(var(--cube) * (var(--oy) - var(--iso-scale)))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
justify-content: center;
|
||||
.face-text.top,
|
||||
.face-text.right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
}
|
||||
|
||||
.left {
|
||||
--fc: var(--face-left-color);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
transform: rotate(30deg) skewX(30deg)
|
||||
translate(
|
||||
calc(var(--cube) * var(--ox)),
|
||||
calc(var(--cube) * var(--oy))
|
||||
)
|
||||
scaleY(var(--iso-scale));
|
||||
.face-text.top { justify-content: center; text-transform: uppercase; }
|
||||
.face-text.right { justify-content: space-between; }
|
||||
.face-text p { margin: 0; }
|
||||
.face-text .height {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&.skeleton {
|
||||
pointer-events: none;
|
||||
.face {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.fees {
|
||||
.face-text .fees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
/* Pool line: raw (un-tinted) logo + miner name, ellipsis-clipped. */
|
||||
.face-text .pool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1em;
|
||||
width: 100%;
|
||||
}
|
||||
.face-text .pool img {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.face-text .pool span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Per-face geometry. The 3 unique orientations each cover two
|
||||
faces (top+bottom share the iso-squash orient; right/left and
|
||||
their rear twins share the side-face orients). --sy is only
|
||||
shared on the top/bottom pair (always full iso rhombus);
|
||||
side faces inherit --sy from their role (liquid/glass/face-text). */
|
||||
.top, .bottom {
|
||||
--orient: rotate(30deg) skewX(-30deg);
|
||||
--sy: var(--iso-scale);
|
||||
}
|
||||
.right, .rear-left { --orient: rotate(-30deg) skewX(-30deg); }
|
||||
.left, .rear-right { --orient: rotate(30deg) skewX(30deg); }
|
||||
|
||||
.bottom {
|
||||
--fc: var(--face-bottom);
|
||||
--x: calc(var(--ox) + 1 + var(--oy) / var(--iso-scale));
|
||||
--y: var(--oy);
|
||||
}
|
||||
.top {
|
||||
--fc: var(--face-top);
|
||||
--x: calc(var(--ox) + var(--top-x-shift, 0) + var(--oy) / var(--iso-scale));
|
||||
--y: calc(var(--oy) - var(--iso-scale) + var(--y-offset));
|
||||
}
|
||||
.liquid.top { --top-x-shift: calc(1 - var(--fill)); }
|
||||
.right {
|
||||
--fc: var(--face-right);
|
||||
--x: calc(var(--ox) + 1);
|
||||
--y: calc((var(--ox) + 1) * var(--iso-scale) + var(--oy) + var(--y-offset));
|
||||
}
|
||||
.left {
|
||||
--fc: var(--face-left);
|
||||
--x: var(--ox);
|
||||
--y: calc(var(--oy) + var(--y-offset));
|
||||
}
|
||||
.rear-right {
|
||||
--fc: var(--face-left);
|
||||
--x: calc(var(--ox) + 1);
|
||||
--y: calc(var(--oy) - var(--iso-scale) + var(--y-offset));
|
||||
}
|
||||
.rear-left {
|
||||
--fc: var(--face-top);
|
||||
--sx: -1;
|
||||
--x: calc(var(--ox) + 1);
|
||||
--y: calc(var(--ox) * var(--iso-scale) + var(--oy) + var(--y-offset));
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-bottom: var(--block-gap);
|
||||
|
||||
Reference in New Issue
Block a user