website: redesign part 1

This commit is contained in:
nym21
2026-06-03 12:34:05 +02:00
parent 5f5563fece
commit 90e8741fb7
209 changed files with 23945 additions and 176 deletions
-58
View File
@@ -1,58 +0,0 @@
/**
* @param {number} [fill]
*/
export function createCubeAnchor(fill = 1) {
const el = document.createElement("a");
return { el, ...populateCube(el, fill) };
}
/**
* @param {number} [fill]
*/
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;
}
-218
View File
@@ -1,218 +0,0 @@
@property --pulse-mix {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
:root {
--cube-size: 4.5rem;
--iso-scale: calc(sqrt(3) / 2);
--cube-empty-alpha: 0.4;
--face-step: 0.033;
}
.cube {
--cube-width: calc(var(--cube-size) * 2 * var(--iso-scale));
--cube-height: calc(var(--cube-size) * 2);
--cube-face-base: var(--border-color);
--face-top: light-dark(
var(--cube-face-base),
oklch(from var(--cube-face-base) calc(l + var(--face-step) * 2) c h)
);
--face-right: light-dark(
oklch(from var(--cube-face-base) calc(l - var(--face-step) * 2) c h),
var(--cube-face-base)
);
--face-left: light-dark(
oklch(from var(--cube-face-base) calc(l - var(--face-step)) c h),
oklch(from var(--cube-face-base) calc(l + var(--face-step)) c h)
);
--face-bottom: oklch(
from var(--cube-face-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);
user-select: none;
pointer-events: none;
/* Hover/active styling is gated on the anchor tag: only <a> cubes
(confirmed blocks) react to pointer state. Plain <div> projected
previews stay visually inert. --face-color-base is resolved lazily,
so the orange override below feeds the face declarations too. */
&:is(a):hover,
&:is(a):active,
&.selected {
color: var(--background-color);
--face-color-base: var(--inv-border-color);
--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
);
}
&:is(a):active,
&.selected {
color: var(--black);
--face-color-base: var(--orange);
}
&.projected {
animation: cube-pulse 4s ease-in-out infinite;
--cube-face-base: color-mix(
in oklch,
var(--border-color),
var(--background-color) calc(var(--pulse-mix) * 100%)
);
}
/* 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;
}
.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;
}
.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;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
&.top {
justify-content: center;
text-transform: uppercase;
}
&.right {
justify-content: space-between;
}
&.left {
justify-content: center;
}
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;
}
.liquid.top {
--face-x: calc(1 - var(--fill));
}
}
@keyframes cube-pulse {
0%,
100% {
--pulse-mix: 0.5;
}
50% {
--pulse-mix: 1;
}
}
-513
View File
@@ -1,513 +0,0 @@
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 {HTMLElement} */ let explorerEl;
/** @type {HTMLDivElement} */ let chainEl;
/** @type {HTMLDivElement} */ let scrollEl;
/** @type {HTMLDivElement} */ let blocksEl;
/** @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 {ReturnType<typeof createProjectedCube>[]} */
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;
explorerEl = parent;
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("scroll");
chainEl.append(scrollEl);
blocksEl = document.createElement("div");
blocksEl.classList.add("blocks");
scrollEl.append(blocksEl);
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 blocksEl.children) {
if (!cube.classList.contains("projected")) cube.classList.add("skeleton");
}
explorerEl.classList.add("loading");
let startHash;
try {
const height = await resolveHeight(hashOrHeight);
startHash = await loadInitial(height);
} catch (_) {
try {
startHash = await loadInitial(null);
} catch (_) {
explorerEl.classList.remove("loading");
return;
}
}
selectCube(/** @type {HTMLAnchorElement} */ (findCube(startHash)), {
scroll: "instant",
silent,
});
explorerEl.classList.remove("loading");
}
export async function poll() {
if (!reachedTip) return;
pollProjected();
try {
const blocks = await brk.getBlocksV1();
appendNewerBlocks(blocks);
} catch (e) {
console.error("explorer poll:", e);
}
}
function pollProjected() {
return brk
.getMempoolBlocks()
.then(renderProjected)
.catch((e) => console.error("mempool poll:", e));
}
/** @param {BlockHash | Height | null} [hashOrHeight] */
function findCube(hashOrHeight) {
if (hashOrHeight == null) {
return reachedTip && newestHeight >= 0 ? newestConfirmedCube() : null;
}
const attr = typeof hashOrHeight === "number" ? "height" : "hash";
return /** @type {HTMLAnchorElement | null} */ (
blocksEl.querySelector(`[data-${attr}="${hashOrHeight}"]`)
);
}
function firstProjectedCube() {
return projectedCubes[0]?.el ?? null;
}
function newestConfirmedCube() {
const firstProj = firstProjectedCube();
return /** @type {HTMLAnchorElement | null} */ (
firstProj ? firstProj.previousElementSibling : blocksEl.lastElementChild
);
}
function clear() {
newestHeight = -1;
oldestHeight = Infinity;
newestTimestamp = 0;
loadingOlder = false;
loadingNewer = false;
reachedTip = false;
selectedCube = null;
blocksEl.innerHTML = "";
projectedCubes.length = 0;
olderEdgeObserver.disconnect();
}
function observeOldestEdge() {
olderEdgeObserver.disconnect();
const oldest = blocksEl.firstElementChild;
if (oldest) olderEdgeObserver.observe(oldest);
}
/** @param {BlockInfoV1[]} blocks */
function appendNewerBlocks(blocks) {
if (!blocks.length) return false;
const anchor = newestConfirmedCube();
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;
refreshProjected();
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();
// Await the projected cubes so the layout is complete before the caller
// scrolls to the tip; otherwise they load late and push the tip out of view.
else await pollProjected();
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;
await pollProjected();
}
} 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 oldFirst = /** @type {HTMLElement | null} */ (blocksEl.firstElementChild);
blocksEl.insertBefore(cube, oldFirst);
if (oldFirst) setConfirmedInterval(oldFirst);
}
/** @param {HTMLAnchorElement} cube */
function appendConfirmed(cube) {
blocksEl.insertBefore(cube, firstProjectedCube());
setConfirmedInterval(cube);
}
/** @param {MempoolBlock[]} blocks */
function renderProjected(blocks) {
const want = Math.min(blocks.length, PROJECTED_LIMIT);
while (projectedCubes.length > want) {
const last = projectedCubes.pop();
if (last) last.el.remove();
}
while (projectedCubes.length < want) {
const cube = createProjectedCube();
projectedCubes.push(cube);
blocksEl.append(cube.el);
}
for (let i = 0; i < want; i++) updateProjectedCube(projectedCubes[i], blocks[i]);
refreshProjected();
}
function createProjectedCube() {
const cube = createCubeDiv();
cube.el.classList.add("projected");
const date = document.createTextNode("");
const hh = document.createTextNode("");
const mm = document.createTextNode("");
const dateP = document.createElement("p");
dateP.append(date);
const timeP = document.createElement("p");
timeP.append(hh, span(":", "dim"), mm);
cube.topFace.append(dateP, timeP);
const txs = document.createTextNode("");
const txsUnit = document.createTextNode("");
const txsP = document.createElement("p");
txsP.append(txs);
const txsUnitP = document.createElement("p");
txsUnitP.classList.add("dim");
txsUnitP.append(txsUnit);
cube.rightFace.append(txsP, txsUnitP);
const median = document.createTextNode("");
const rangeLo = document.createTextNode("");
const rangeHi = document.createTextNode("");
const medianP = document.createElement("p");
medianP.append(span("~", "dim"), median);
const rangeP = document.createElement("p");
rangeP.append(rangeLo, span("-", "dim"), rangeHi);
const unitP = document.createElement("p");
unitP.classList.add("dim");
unitP.textContent = "sat/vB";
cube.leftFace.append(medianP, rangeP, unitP);
return {
...cube,
parts: { date, hh, mm, txs, txsUnit, median, rangeLo, rangeHi },
};
}
/**
* @param {ReturnType<typeof createProjectedCube>} cube
* @param {MempoolBlock} block
*/
function updateProjectedCube(cube, block) {
cube.el.style.setProperty(
"--fill",
String(Math.min(1, block.blockVSize / 1_000_000)),
);
const p = cube.parts;
p.txs.nodeValue = block.nTx.toLocaleString();
p.txsUnit.nodeValue = block.nTx === 1 ? "tx" : "txs";
p.median.nodeValue = formatFeeRate(block.medianFee);
p.rangeLo.nodeValue = formatFeeRate(block.feeRange[0]);
p.rangeHi.nodeValue = formatFeeRate(block.feeRange[6]);
}
function refreshProjected() {
if (!projectedCubes.length || !newestTimestamp) return;
const now = Math.floor(Date.now() / 1000);
const elapsed = Math.max(0, now - newestTimestamp);
for (let i = 0; i < projectedCubes.length; i++) {
const cube = projectedCubes[i];
const interval = i === 0 ? elapsed : TARGET_BLOCK_SECONDS;
cube.el.style.setProperty("--block-interval", String(interval));
const ts = now + i * TARGET_BLOCK_SECONDS;
const [hh, mm] = formatHHMM(ts);
cube.parts.date.nodeValue = formatShortDate(ts);
cube.parts.hh.nodeValue = hh;
cube.parts.mm.nodeValue = mm;
}
}
/** @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("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"),
];
}
-134
View File
@@ -1,134 +0,0 @@
#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%;
}
.scroll {
padding: 0 var(--main-padding);
scrollbar-width: none;
@container aside (max-width: 767px) {
padding-bottom: 1rem;
overflow-x: auto;
}
@container aside (min-width: 768px) {
padding: var(--main-padding) calc(var(--main-padding) / 2) 6rem
var(--main-padding);
height: 100%;
overflow-y: auto;
}
.blocks {
display: flex;
flex-direction: column-reverse;
@container aside (max-width: 767px) {
flex-direction: row-reverse;
width: max-content;
}
.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);
@container aside (max-width: 767px) {
margin-bottom: 0;
margin-right: var(--block-gap);
}
}
.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;
}
}
}
}
}
.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;
}
}
}
-170
View File
@@ -1,170 +0,0 @@
import { createSelect } from "../../../scripts/utils/dom.js";
import { GENESIS_DATE, todayISODate, toISODate } from "../time.js";
import { createHeatmapPersistedValue, findChoiceByKey } from "./shared.js";
/**
* @typedef {Object} RangeChoice
* @property {string} label
* @property {string} date
*/
/**
* @param {HeatmapOption} option
* @param {(range: { from: string, to: string }) => void} onChange
*/
export function createDateControls(option, onChange) {
const currentYear = new Date().getUTCFullYear();
const fromChoices = createFromChoices(currentYear);
const toChoices = createToChoices(currentYear);
const fallbackFromChoice = fromChoices.at(-1) ?? fromChoices[0];
const fallbackToChoice = toChoices[0];
const defaultFromChoice = findChoiceByKey(
fromChoices,
option.defaults?.from ?? "",
fallbackFromChoice,
rangeChoiceLabel,
);
const defaultToChoice = findChoiceByKey(
toChoices,
option.defaults?.to ?? "",
fallbackToChoice,
rangeChoiceLabel,
);
const persistedFrom = createHeatmapPersistedValue(
option,
"from",
"from",
rangeChoiceLabel(defaultFromChoice),
);
const persistedTo = createHeatmapPersistedValue(
option,
"to",
"to",
rangeChoiceLabel(defaultToChoice),
);
let fromChoice = findChoiceByKey(
fromChoices,
persistedFrom.value,
defaultFromChoice,
rangeChoiceLabel,
);
let toChoice = findChoiceByKey(
toChoices,
persistedTo.value,
defaultToChoice,
rangeChoiceLabel,
);
if (fromChoice.date > toChoice.date) {
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
}
persistDateChoices();
const fromSelect = createSelect({
id: "heatmap-from",
label: "from",
choices: fromChoices,
initialValue: fromChoice,
onChange(choice) {
fromChoice = choice;
if (fromChoice.date > toChoice.date) {
toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice);
toSelect.set(toChoice);
}
persistDateChoices();
onChange({ from: fromChoice.date, to: toChoice.date });
},
toKey: rangeChoiceLabel,
toLabel: rangeChoiceLabel,
});
const toSelect = createSelect({
id: "heatmap-to",
label: "to",
choices: toChoices,
initialValue: toChoice,
onChange(choice) {
toChoice = choice;
if (fromChoice.date > toChoice.date) {
fromChoice = findSameLabelChoice(
fromChoices,
toChoice,
defaultFromChoice,
);
fromSelect.set(fromChoice);
}
persistDateChoices();
onChange({ from: fromChoice.date, to: toChoice.date });
},
toKey: rangeChoiceLabel,
toLabel: rangeChoiceLabel,
});
return {
elements: [fromSelect.element, toSelect.element],
from: fromChoice.date,
to: toChoice.date,
};
function persistDateChoices() {
persistedFrom.setImmediate(rangeChoiceLabel(fromChoice));
persistedTo.setImmediate(rangeChoiceLabel(toChoice));
}
}
/**
* @param {number} currentYear
* @returns {RangeChoice[]}
*/
function createFromChoices(currentYear) {
const choices = [{ label: "genesis", date: GENESIS_DATE }];
for (let year = 2009; year <= currentYear; year++) {
choices.push({
label: String(year),
date: year === 2009 ? GENESIS_DATE : yearStartISODate(year),
});
}
return choices;
}
/**
* @param {number} currentYear
* @returns {RangeChoice[]}
*/
function createToChoices(currentYear) {
const today = todayISODate();
const todayTime = Date.parse(`${today}T00:00:00Z`);
const choices = [{ label: "today", date: today }];
for (let year = currentYear; year >= 2009; year--) {
choices.push({ label: String(year), date: yearEndISODate(year, todayTime) });
}
return choices;
}
/** @param {RangeChoice} choice */
function rangeChoiceLabel(choice) {
return choice.label;
}
/**
* @param {readonly RangeChoice[]} choices
* @param {RangeChoice} choice
* @param {RangeChoice} fallback
*/
function findSameLabelChoice(choices, choice, fallback) {
return choices.find((candidate) => candidate.label === choice.label) ?? fallback;
}
/** @param {number} year */
function yearStartISODate(year) {
return toISODate(new Date(Date.UTC(year, 0, 1)));
}
/**
* @param {number} year
* @param {number} todayTime
*/
function yearEndISODate(year, todayTime) {
return toISODate(new Date(Math.min(Date.UTC(year, 11, 31), todayTime)));
}
-38
View File
@@ -1,38 +0,0 @@
import { createDateControls } from "./dates.js";
import { createYControls } from "./y.js";
/**
* @typedef {Object} HeatmapControlSelection
* @property {string} from
* @property {string} to
* @property {number | undefined} yMin
* @property {number | undefined} yMax
*/
/**
* @param {Object} args
* @param {(range: { from: string, to: string }) => void} args.onRangeChange
* @param {(range: { yMin: number | undefined, yMax: number | undefined }) => void} args.onYRangeChange
*/
export function createHeatmapControls({ onRangeChange, onYRangeChange }) {
const element = document.createElement("fieldset");
return {
element,
/**
* @param {HeatmapOption} option
* @returns {HeatmapControlSelection}
*/
setOption(option) {
const dates = createDateControls(option, onRangeChange);
const y = createYControls(option, onYRangeChange);
element.replaceChildren(...dates.elements, ...y.elements);
return {
from: dates.from,
to: dates.to,
yMin: y.yMin,
yMax: y.yMax,
};
},
};
}
-33
View File
@@ -1,33 +0,0 @@
import { createPersistedValue } from "../../../scripts/utils/persisted.js";
/**
* @param {HeatmapOption} option
* @param {string} key
* @param {string} urlKey
* @param {string} defaultValue
*/
export function createHeatmapPersistedValue(option, key, urlKey, defaultValue) {
return createPersistedValue({
defaultValue,
storageKey: `${heatmapStoragePrefix(option)}-${key}`,
urlKey,
serialize: (value) => value,
deserialize: (value) => value,
});
}
/**
* @template T
* @param {readonly T[]} choices
* @param {string} key
* @param {T} fallback
* @param {(choice: T) => string} toKey
*/
export function findChoiceByKey(choices, key, fallback, toKey) {
return choices.find((candidate) => toKey(candidate) === key) ?? fallback;
}
/** @param {HeatmapOption} option */
function heatmapStoragePrefix(option) {
return `heatmap-${option.path.join("-")}`;
}
-119
View File
@@ -1,119 +0,0 @@
import { createSelect } from "../../../scripts/utils/dom.js";
import { createHeatmapPersistedValue, findChoiceByKey } from "./shared.js";
/**
* @param {HeatmapOption} option
* @param {(range: { yMin: number | undefined, yMax: number | undefined }) => void} onChange
*/
export function createYControls(option, onChange) {
const y = option.axis?.y;
const choices = y?.choices;
if (!choices || choices.length < 2) {
return { elements: [], yMin: undefined, yMax: undefined };
}
const fallbackMinChoice = choices[0];
const fallbackMaxChoice = choices.at(-1) ?? choices[0];
const defaultMinChoice = findChoiceByKey(
choices,
String(option.defaults?.yMin ?? ""),
fallbackMinChoice,
axisChoiceValueKey,
);
const defaultMaxChoice = findChoiceByKey(
choices,
String(option.defaults?.yMax ?? ""),
fallbackMaxChoice,
axisChoiceValueKey,
);
const persistedMin = createHeatmapPersistedValue(
option,
"y-min",
"min",
axisChoiceKey(defaultMinChoice),
);
const persistedMax = createHeatmapPersistedValue(
option,
"y-max",
"max",
axisChoiceKey(defaultMaxChoice),
);
let minChoice = findChoiceByKey(
choices,
persistedMin.value,
defaultMinChoice,
axisChoiceKey,
);
let maxChoice = findChoiceByKey(
choices,
persistedMax.value,
defaultMaxChoice,
axisChoiceKey,
);
if (minChoice.value > maxChoice.value) {
maxChoice = minChoice;
}
persistYChoices();
const minSelect = createSelect({
id: "heatmap-y-min",
label: "min",
choices,
initialValue: minChoice,
onChange(choice) {
minChoice = choice;
if (minChoice.value > maxChoice.value) {
maxChoice = minChoice;
maxSelect.set(maxChoice);
}
persistYChoices();
onChange({ yMin: minChoice.value, yMax: maxChoice.value });
},
toKey: axisChoiceKey,
toLabel: axisChoiceLabel,
});
const maxSelect = createSelect({
id: "heatmap-y-max",
label: "max",
choices: Array.from(choices).reverse(),
initialValue: maxChoice,
onChange(choice) {
maxChoice = choice;
if (minChoice.value > maxChoice.value) {
minChoice = maxChoice;
minSelect.set(minChoice);
}
persistYChoices();
onChange({ yMin: minChoice.value, yMax: maxChoice.value });
},
toKey: axisChoiceKey,
toLabel: axisChoiceLabel,
});
return {
elements: [minSelect.element, maxSelect.element],
yMin: minChoice.value,
yMax: maxChoice.value,
};
function persistYChoices() {
persistedMin.setImmediate(axisChoiceKey(minChoice));
persistedMax.setImmediate(axisChoiceKey(maxChoice));
}
}
/** @param {HeatmapAxisChoice} choice */
function axisChoiceKey(choice) {
return choice.key ?? choice.label;
}
/** @param {HeatmapAxisChoice} choice */
function axisChoiceLabel(choice) {
return choice.label;
}
/** @param {HeatmapAxisChoice} choice */
function axisChoiceValueKey(choice) {
return String(choice.value);
}
-17
View File
@@ -1,17 +0,0 @@
/** @param {number} value */
export function formatCompact(value) {
if (value >= 1000) return `${formatNumber(value / 1000)}k`;
return formatNumber(value);
}
/** @param {number} value */
function formatNumber(value) {
if (value >= 100) return String(Math.round(value));
if (value >= 10) return trimNumber(value.toFixed(1));
return trimNumber(value.toFixed(2));
}
/** @param {string} value */
function trimNumber(value) {
return value.replace(/\.?0+$/, "");
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Generic date/y binning with average merge semantics.
*
* @param {Object} args
* @param {number} args.yMin
* @param {number} args.yMax
* @param {number} [args.minCellSize]
* @param {number} [args.maxCols]
* @param {number} [args.nativeRows]
* @param {"bottom" | "top"} [args.yOrigin]
* @returns {HeatmapGridFactory}
*/
export function createAverageGrid({
yMin: defaultYMin,
yMax: defaultYMax,
minCellSize = 1,
maxCols = Number.POSITIVE_INFINITY,
nativeRows = Number.POSITIVE_INFINITY,
yOrigin = "bottom",
}) {
return {
create({ dates, width, height, yMin = defaultYMin, yMax = defaultYMax }) {
const cols = Math.max(
1,
Math.min(
dates.length || 1,
maxCols,
Math.floor(width / minCellSize) || 1,
),
);
const rows = Math.max(
1,
Math.min(nativeRows, Math.floor(height / minCellSize) || 1),
);
const sums = new Float64Array(cols * rows);
const counts = new Uint32Array(cols * rows);
const maxByCol = new Float64Array(cols);
const magnitudeMaxByCol = new Float64Array(cols);
let maxValue = 0;
let magnitudeMaxValue = 0;
const ySpan = yMax - yMin;
/** @param {number} dateIndex */
function toCol(dateIndex) {
if (dateIndex < 0 || dateIndex >= dates.length) return undefined;
return clamp(
Math.floor((dateIndex * cols) / dates.length),
0,
cols - 1,
);
}
/** @param {number} y */
function toRow(y) {
if (!Number.isFinite(y) || !Number.isFinite(ySpan) || ySpan <= 0) {
return undefined;
}
const t = (y - yMin) / ySpan;
if (t < 0 || t > 1) return undefined;
const row = clamp(Math.floor(t * rows), 0, rows - 1);
return yOrigin === "top" ? row : rows - 1 - row;
}
/**
* @param {number} col
* @param {number} y
* @param {number} value
*/
function addValue(col, y, value) {
if (!Number.isFinite(value)) return false;
const row = toRow(y);
if (row === undefined) return false;
const index = row * cols + col;
sums[index] += value;
counts[index] += 1;
return true;
}
/** @param {number} col */
function updateColumnMax(col) {
let max = 0;
let magnitudeMax = 0;
for (let row = 0; row < rows; row++) {
const index = row * cols + col;
if (counts[index]) {
const value = sums[index] / counts[index];
max = Math.max(max, value);
magnitudeMax = Math.max(magnitudeMax, Math.abs(value));
}
}
maxByCol[col] = max;
magnitudeMaxByCol[col] = magnitudeMax;
maxValue = 0;
magnitudeMaxValue = 0;
for (let c = 0; c < cols; c++) {
maxValue = Math.max(maxValue, maxByCol[c]);
magnitudeMaxValue = Math.max(magnitudeMaxValue, magnitudeMaxByCol[c]);
}
}
/** @type {HeatmapGrid} */
const grid = {
dates,
cols,
rows,
add(dateIndex, points) {
const col = toCol(dateIndex);
if (col === undefined) return undefined;
let dirty = false;
if (points.kind === "implicit") {
for (let i = 0; i < points.values.length; i++) {
if (
addValue(
col,
points.yStart + i * points.yStep,
points.values[i],
)
) {
dirty = true;
}
}
} else {
const length = Math.min(points.y.length, points.values.length);
for (let i = 0; i < length; i++) {
if (addValue(col, points.y[i], points.values[i])) dirty = true;
}
}
if (!dirty) return undefined;
const previousMax = maxValue;
const previousMagnitudeMax = magnitudeMaxValue;
updateColumnMax(col);
return {
col,
maxChanged:
maxValue !== previousMax ||
magnitudeMaxValue !== previousMagnitudeMax,
};
},
getValue(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) {
return Number.NaN;
}
const index = row * cols + col;
return counts[index] ? sums[index] / counts[index] : Number.NaN;
},
getCount(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) return 0;
return counts[row * cols + col];
},
getMaxValue(col) {
if (col === undefined) return maxValue;
if (col < 0 || col >= cols) return 0;
return maxByCol[col];
},
getMagnitudeMaxValue(col) {
if (col === undefined) return magnitudeMaxValue;
if (col < 0 || col >= cols) return 0;
return magnitudeMaxByCol[col];
},
getDateIndexRange(col) {
if (col < 0 || col >= cols || dates.length === 0) {
return emptyRange();
}
const start = Math.ceil((col * dates.length) / cols);
const end = Math.floor(((col + 1) * dates.length - 1) / cols);
return { start, end: clamp(end, start, dates.length - 1) };
},
getYRange(row) {
if (row < 0 || row >= rows || ySpan <= 0) return emptyRange();
const index = yOrigin === "top" ? row : rows - row - 1;
const start = yMin + (index / rows) * ySpan;
const end = yMin + ((index + 1) / rows) * ySpan;
return { start, end };
},
};
return grid;
},
};
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/** @returns {HeatmapRange} */
function emptyRange() {
return { start: Number.NaN, end: Number.NaN };
}
-277
View File
@@ -1,277 +0,0 @@
import { createHeader } from "../../scripts/utils/dom.js";
import { heatmapElement } from "../../scripts/utils/elements.js";
import { debounce, next } from "../../scripts/utils/timing.js";
import { createHeatmapControls } from "./controls/index.js";
import { createHeatmapLoader } from "./loader.js";
import { createRenderer } from "./renderer.js";
import { createTooltipView } from "./tooltip/view.js";
/** @type {ReturnType<typeof createRenderer> | undefined} */
let renderer;
/** @type {HTMLCanvasElement | undefined} */
let canvas;
/** @type {ReturnType<typeof createTooltipView> | undefined} */
let tooltipView;
/** @type {ReturnType<typeof createHeatmapControls> | undefined} */
let controls;
/** @type {ReturnType<typeof createHeatmapLoader> | undefined} */
let loader;
/** @type {HTMLHeadingElement | undefined} */
let headingElement;
/** @type {HeatmapOption | undefined} */
let currentOption;
/** @type {HeatmapGrid | undefined} */
let currentGrid;
const dirtyCols = new Set();
let paintScheduled = false;
let initialized = false;
let from = "";
let to = "";
/** @type {number | undefined} */
let yMin;
/** @type {number | undefined} */
let yMax;
/**
* Initializes the heatmap pane once for the app lifetime.
*/
export function init() {
if (initialized) return;
initialized = true;
const header = createHeader();
headingElement = header.headingElement;
const { headerElement } = header;
controls = createHeatmapControls({
onRangeChange(range) {
from = range.from;
to = range.to;
hideTooltip();
loadRange();
},
onYRangeChange(range) {
yMin = range.yMin;
yMax = range.yMax;
hideTooltip();
rebuildAndLoadVisibleDates();
},
});
heatmapElement.append(headerElement);
heatmapElement.append(controls.element);
canvas = document.createElement("canvas");
heatmapElement.append(canvas);
renderer = createRenderer(canvas);
loader = createHeatmapLoader({ addDateToGrid, rebuildGrid, paint });
tooltipView = createTooltipView(heatmapElement);
canvas.addEventListener("pointermove", updateHoverTooltip);
canvas.addEventListener("pointerdown", updateTapTooltip);
canvas.addEventListener("pointerleave", hideHoverTooltip);
canvas.addEventListener("pointercancel", hideTooltip);
void next().then(resizeAndRebuild);
new ResizeObserver(
debounce(() => {
resizeAndRebuild();
}, 250),
).observe(heatmapElement);
}
/** @param {HeatmapOption} option */
export function setOption(option) {
init();
if (currentOption !== option) {
currentOption = option;
loader?.reset();
const selection = controls?.setOption(option);
if (selection) {
from = selection.from;
to = selection.to;
yMin = selection.yMin;
yMax = selection.yMax;
}
if (headingElement) headingElement.textContent = option.title;
hideTooltip();
}
loadRange();
}
function resizeAndRebuild() {
if (!canvas || !renderer) return;
const { width, height } = canvas.getBoundingClientRect();
if (renderer.resize(width, height)) rebuildAndLoadVisibleDates();
}
function loadRange() {
if (!currentOption || !loader) return;
loader.setRange({ option: currentOption, from, to });
rebuildAndLoadVisibleDates();
}
function rebuildAndLoadVisibleDates() {
rebuildGrid();
loadVisibleDates();
}
function rebuildGrid() {
const dates = loader?.dates;
if (
!currentOption ||
!renderer ||
!loader ||
!dates?.length ||
renderer.width < 1 ||
renderer.height < 1
) {
currentGrid = undefined;
return;
}
currentGrid = currentOption.grid.create({
dates,
width: renderer.width,
height: renderer.height,
yMin,
yMax,
});
for (const dateIndex of getVisibleDateIndexes(currentGrid)) {
const points = loader.getPoint(dates[dateIndex]);
if (points) currentGrid.add(dateIndex, points);
}
paint();
}
function loadVisibleDates() {
if (!currentOption || !loader || !currentGrid) return;
loader.load({
option: currentOption,
dateIndexes: getVisibleDateIndexes(currentGrid),
});
}
/**
* @param {HeatmapGrid} grid
* @returns {number[]}
*/
function getVisibleDateIndexes(grid) {
/** @type {number[]} */
const indexes = [];
let previousDateIndex = -1;
for (let col = 0; col < grid.cols; col++) {
const dateIndex = grid.getDateIndexRange(col).end;
if (!Number.isInteger(dateIndex) || dateIndex === previousDateIndex) {
continue;
}
previousDateIndex = dateIndex;
indexes.push(dateIndex);
}
return indexes;
}
/**
* @param {number} dateIndex
* @param {HeatmapPoints} points
*/
function addDateToGrid(dateIndex, points) {
if (!currentGrid) return;
const result = currentGrid.add(dateIndex, points);
if (!result) return;
if (result.maxChanged) {
paint();
} else {
schedulePaint(result.col);
}
}
/** @param {number} col */
function schedulePaint(col) {
dirtyCols.add(col);
if (paintScheduled) return;
paintScheduled = true;
requestAnimationFrame(() => {
paintScheduled = false;
if (!dirtyCols.size) return;
paint(dirtyCols);
dirtyCols.clear();
});
}
/** @param {Iterable<number>} [dirty] */
function paint(dirty) {
if (!renderer || !currentGrid || !currentOption) return;
const grid = currentGrid;
const option = currentOption;
renderer.paint(
grid.cols,
grid.rows,
(col, row) => option.color(grid.getValue(col, row), { grid, col, row }),
dirty,
);
}
/** @param {PointerEvent} event */
function updateHoverTooltip(event) {
if (event.pointerType !== "mouse") return;
updateTooltip(event, "auto");
}
/** @param {PointerEvent} event */
function updateTapTooltip(event) {
if (event.pointerType === "mouse") return;
updateTooltip(event, "above");
}
/** @param {PointerEvent} event */
function hideHoverTooltip(event) {
if (event.pointerType === "mouse") hideTooltip();
}
/**
* @param {PointerEvent} event
* @param {"auto" | "above"} placement
*/
function updateTooltip(event, placement) {
if (!canvas || !currentGrid || !currentOption?.tooltip || !tooltipView) {
hideTooltip();
return;
}
const rect = canvas.getBoundingClientRect();
const col = Math.floor(
((event.clientX - rect.left) * currentGrid.cols) / rect.width,
);
const row = Math.floor(
((event.clientY - rect.top) * currentGrid.rows) / rect.height,
);
if (
col < 0 ||
col >= currentGrid.cols ||
row < 0 ||
row >= currentGrid.rows
) {
hideTooltip();
return;
}
if (currentGrid.getCount(col, row) === 0) {
hideTooltip();
return;
}
tooltipView.show(
event,
currentOption.tooltip({
option: currentOption,
grid: currentGrid,
col,
row,
}),
{ placement },
);
}
function hideTooltip() {
tooltipView?.hide();
}
-172
View File
@@ -1,172 +0,0 @@
import { dateRange } from "./time.js";
const MAX_PARALLEL_FETCHES = 8;
/**
* @param {Object} args
* @param {(dateIndex: number, points: HeatmapPoints) => void} args.addDateToGrid
* @param {() => void} args.rebuildGrid
* @param {() => void} args.paint
*/
export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
/** @type {string[]} */
let dates = [];
/** @type {Map<string, HeatmapPoints>} */
let pointsByDate = new Map();
/** @type {AbortController | undefined} */
let abortController;
/** @type {HeatmapOption | undefined} */
let activeOption;
let generation = 0;
return {
get dates() {
return dates;
},
/** @param {string} date */
getPoint(date) {
return pointsByDate.get(date);
},
reset() {
abortController?.abort();
pointsByDate = new Map();
},
/**
* @param {Object} args
* @param {HeatmapOption} args.option
* @param {string} args.from
* @param {string} args.to
*/
setRange({ option, from, to }) {
abortController?.abort();
generation += 1;
activeOption = option;
dates = dateRange(from, to);
},
/**
* @param {Object} args
* @param {HeatmapOption} args.option
* @param {readonly number[]} args.dateIndexes
*/
load({ option, dateIndexes }) {
abortController?.abort();
const controller = new AbortController();
const currentGeneration = ++generation;
activeOption = option;
abortController = controller;
/** @type {{ date: string, dateIndex: number }[]} */
const missing = [];
let previousDateIndex = -1;
for (const dateIndex of dateIndexes) {
if (
dateIndex === previousDateIndex ||
dateIndex < 0 ||
dateIndex >= dates.length
) {
continue;
}
previousDateIndex = dateIndex;
const date = dates[dateIndex];
if (!pointsByDate.has(date)) missing.push({ date, dateIndex });
}
if (!missing.length) {
abortController = undefined;
return;
}
let cursor = 0;
let needsRebuild = false;
const workers = Array.from({
length: Math.min(MAX_PARALLEL_FETCHES, missing.length),
}).map(async () => {
let index = nextMissingIndex();
while (index !== undefined) {
const entry = missing[index];
try {
const points = await option.points.fetch(
entry.date,
controller.signal,
(points) => {
if (isCurrentLoad(option, controller, currentGeneration)) {
setPoints(entry, points);
}
},
);
if (isCurrentLoad(option, controller, currentGeneration)) {
setPoints(entry, points);
}
} catch (error) {
if (controller.signal.aborted) return;
console.error(
`Failed to fetch heatmap points for ${entry.date}`,
error,
);
}
index = nextMissingIndex();
}
});
void Promise.all(workers).then(() => {
if (isCurrentLoad(option, controller, currentGeneration)) {
if (needsRebuild) {
rebuildGrid();
} else {
paint();
}
}
});
function nextMissingIndex() {
if (cursor >= missing.length) return undefined;
const index = cursor;
cursor += 1;
return index;
}
/**
* @param {{ date: string, dateIndex: number }} entry
* @param {HeatmapPoints} points
*/
function setPoints(entry, points) {
const previous = pointsByDate.get(entry.date);
if (previous && samePoints(previous, points)) return;
pointsByDate.set(entry.date, points);
if (previous) {
needsRebuild = true;
} else {
addDateToGrid(entry.dateIndex, points);
}
}
},
};
/**
* @param {HeatmapOption} option
* @param {AbortController} controller
* @param {number} currentGeneration
*/
function isCurrentLoad(option, controller, currentGeneration) {
return (
activeOption === option &&
abortController === controller &&
generation === currentGeneration &&
!controller.signal.aborted
);
}
}
/**
* @param {HeatmapPoints} a
* @param {HeatmapPoints} b
*/
function samePoints(a, b) {
if (a === b) return true;
if (a.kind !== b.kind || a.values !== b.values) return false;
if (a.kind === "implicit" && b.kind === "implicit") {
return a.yStart === b.yStart && a.yStep === b.yStep;
}
if (a.kind === "explicit" && b.kind === "explicit") return a.y === b.y;
return false;
}
-110
View File
@@ -1,110 +0,0 @@
const INFERNO_STOPS = [
[0, 0, 0, 0],
[0.13, 40, 11, 84],
[0.25, 101, 21, 110],
[0.38, 159, 42, 99],
[0.5, 212, 72, 66],
[0.63, 245, 125, 21],
[0.75, 250, 193, 39],
[0.88, 252, 243, 105],
[1, 252, 255, 164],
];
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
const DIVERGING_NEGATIVE_STOPS = [
[0, 0, 0, 0],
[0.25, 60, 0, 0],
[0.5, 140, 10, 0],
[0.75, 200, 30, 10],
[1, 240, 60, 20],
];
const DIVERGING_POSITIVE_STOPS = [
[0, 0, 0, 0],
[0.25, 0, 40, 0],
[0.5, 0, 110, 10],
[0.75, 10, 180, 20],
[1, 30, 230, 50],
];
export const DIVERGING_NEGATIVE_LUT = createColorLut(DIVERGING_NEGATIVE_STOPS);
export const DIVERGING_POSITIVE_LUT = createColorLut(DIVERGING_POSITIVE_STOPS);
/**
* @param {ArrayLike<number>} lut
* @returns {HeatmapColorFn}
*/
export function logIntensityColor(lut) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const max = context.grid.getMaxValue(context.col);
if (max <= 0) return 0x00000000;
const t = Math.log2(value + 1) / Math.log2(max + 1);
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {ArrayLike<number>} lut
* @param {number} [exponent]
* @returns {HeatmapColorFn}
*/
export function powerIntensityColor(lut, exponent = 0.4) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const cap = context.grid.getMaxValue(context.col);
if (cap <= 0) return 0x00000000;
const t = Math.pow(Math.min(1, value / cap), exponent);
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {ArrayLike<number>} negativeLut
* @param {ArrayLike<number>} positiveLut
* @param {number} [exponent]
* @returns {HeatmapColorFn}
*/
export function divergingPowerIntensityColor(
negativeLut,
positiveLut,
exponent = 0.4,
) {
return (value, context) => {
if (!Number.isFinite(value) || value === 0) return 0x00000000;
const cap = context.grid.getMagnitudeMaxValue(context.col);
if (cap <= 0) return 0x00000000;
const t = Math.pow(Math.min(1, Math.abs(value) / cap), exponent);
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
const lut = value < 0 ? negativeLut : positiveLut;
return lut[index] ?? 0x00000000;
};
}
/**
* @param {number[][]} stops - Tuples of [position, red, green, blue].
*/
export function createColorLut(stops) {
const lut = new Uint32Array(256);
for (let i = 0; i < lut.length; i++) {
const t = i / 255;
let a = stops[0];
let b = stops[stops.length - 1];
for (let j = 0; j < stops.length - 1; j++) {
if (t >= stops[j][0] && t <= stops[j + 1][0]) {
a = stops[j];
b = stops[j + 1];
break;
}
}
const f = a[0] === b[0] ? 0 : (t - a[0]) / (b[0] - a[0]);
const r = (a[1] + f * (b[1] - a[1]) + 0.5) | 0;
const g = (a[2] + f * (b[2] - a[2]) + 0.5) | 0;
const blue = (a[3] + f * (b[3] - a[3]) + 0.5) | 0;
lut[i] = 0xff000000 | (blue << 16) | (g << 8) | r;
}
return lut;
}
-140
View File
@@ -1,140 +0,0 @@
import { brk } from "../../scripts/utils/client.js";
import { formatCompact } from "./format.js";
import { createAverageGrid } from "./grid.js";
import { INFERNO_LUT, logIntensityColor } from "./lut.js";
import { defaultTooltip } from "./tooltip/index.js";
const BINS = 2400;
const MIN_LOG = -8;
const BINS_PER_DECADE = 200;
const AMOUNT_CHOICES = [
{ label: "1 sat", key: "1sat", value: -8 },
{ label: "10 sats", key: "10sats", value: -7 },
{ label: "100 sats", key: "100sats", value: -6 },
{ label: "1k sats", key: "1ksats", value: -5 },
{ label: "10k sats", key: "10ksats", value: -4 },
{ label: "100k sats", key: "100ksats", value: -3 },
{ label: "0.01 BTC", key: "0.01btc", value: -2 },
{ label: "0.1 BTC", key: "0.1btc", value: -1 },
{ label: "1 BTC", key: "1btc", value: 0 },
{ label: "10 BTC", key: "10btc", value: 1 },
{ label: "100 BTC", key: "100btc", value: 2 },
{ label: "1k BTC", key: "1kbtc", value: 3 },
{ label: "10k BTC", key: "10kbtc", value: 4 },
];
export const oracleOutputsHeatmapOption = createOracleHeatmapOption(
"outputs",
"All",
);
export const oraclePaymentsHeatmapOption = createOracleHeatmapOption(
"payments",
"Payments",
);
/**
* @param {"outputs" | "payments"} mode
* @param {string} name
* @returns {PartialHeatmapOption}
*/
function createOracleHeatmapOption(mode, name) {
return {
kind: "heatmap",
name,
title:
mode === "outputs" ? "All Output Values" : "Payment Output Values",
points: {
fetch: (date, signal, onPoints) =>
fetchOraclePoints(mode, date, signal, onPoints),
},
grid: createAverageGrid({
yMin: MIN_LOG,
yMax: MIN_LOG + BINS / BINS_PER_DECADE,
nativeRows: BINS,
yOrigin: "top",
}),
color: logIntensityColor(INFERNO_LUT),
axis: {
y: {
label: "amount",
choices: AMOUNT_CHOICES,
format: formatAmount,
},
},
defaults:
mode === "payments"
? {
from: "2015",
to: "today",
yMin: -5,
yMax: 2,
}
: {
from: "genesis",
to: "today",
},
tooltip: defaultTooltip(
mode === "outputs"
? { valueLabel: "Outputs" }
: { valueLabel: "Payment signal" },
),
};
}
/**
* @param {"outputs" | "payments"} mode
* @param {string} date
* @param {AbortSignal} signal
* @param {(points: HeatmapPoints) => void} [onPoints]
* @returns {Promise<HeatmapPoints>}
*/
async function fetchOraclePoints(mode, date, signal, onPoints) {
const values = await fetchOracleValues(
mode,
date,
signal,
onPoints ? (values) => onPoints(toOraclePoints(values)) : undefined,
);
return toOraclePoints(values);
}
/**
* @param {"outputs" | "payments"} mode
* @param {string} date
* @param {AbortSignal} signal
* @param {(values: number[]) => void} [onValue]
* @returns {Promise<number[]>}
*/
function fetchOracleValues(mode, date, signal, onValue) {
return (
mode === "outputs"
? brk.getOracleHistogramOutputs(date, { signal, onValue })
: brk.getOracleHistogramPayments(date, { signal, onValue })
);
}
/**
* @param {number[]} values
* @returns {HeatmapPoints}
*/
function toOraclePoints(values) {
return {
kind: "implicit",
yStart: MIN_LOG,
yStep: 1 / BINS_PER_DECADE,
values,
};
}
/** @param {number} value */
function formatAmount(value) {
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.001) {
const choice = AMOUNT_CHOICES.find((choice) => choice.value === rounded);
if (choice) return choice.label;
}
const btc = 10 ** value;
if (btc >= 1) return `${formatCompact(btc)} BTC`;
return `${formatCompact(btc * 100_000_000)} sats`;
}
-116
View File
@@ -1,116 +0,0 @@
/** @param {HTMLCanvasElement} canvas */
export function createRenderer(canvas) {
const context = canvas.getContext("2d");
if (!context) throw "Expected context from canvas";
let width = 0;
let height = 0;
let imageData = new ImageData(1, 1);
let buffer = new Uint32Array();
const emptyGeometry = {
cols: -1,
rows: -1,
colX: new Int32Array(0),
rowOffset: new Int32Array(0),
};
let geometry = { ...emptyGeometry };
/**
* @param {number} cols
* @param {number} rows
*/
function getGeometry(cols, rows) {
if (geometry.cols === cols && geometry.rows === rows) return geometry;
const colX = new Int32Array(cols + 1);
for (let c = 0; c <= cols; c++) {
colX[c] = ((c * width) / cols + 0.5) | 0;
}
const rowOffset = new Int32Array(rows + 1);
for (let r = 0; r <= rows; r++) {
rowOffset[r] = (((r * height) / rows + 0.5) | 0) * width;
}
geometry = { cols, rows, colX, rowOffset };
return geometry;
}
/**
* @param {number} col
* @param {number} rows
* @param {number} x0
* @param {number} x1
* @param {Int32Array} rowOffset
* @param {(col: number, row: number) => number} getColor
*/
function paintColumn(col, rows, x0, x1, rowOffset, getColor) {
if (x0 === x1) return false;
for (let r = 0; r < rows; r++) {
const color = getColor(col, r);
for (let off = rowOffset[r]; off < rowOffset[r + 1]; off += width) {
buffer.fill(color, off + x0, off + x1);
}
}
return true;
}
return {
/**
* @param {number} w
* @param {number} h
* @returns {boolean} whether the canvas was actually resized (true) or not (false)
*/
resize(w, h) {
const bound = canvas.getBoundingClientRect();
const nextWidth = Math.floor(Math.min(w, bound.width));
const nextHeight = Math.floor(Math.min(h, bound.height));
if (nextWidth < 1 || nextHeight < 1) return false;
if (nextWidth === width && nextHeight === height) return false;
width = nextWidth;
height = nextHeight;
canvas.width = width;
canvas.height = height;
geometry = { ...emptyGeometry };
imageData = context.createImageData(width, height);
buffer = new Uint32Array(imageData.data.buffer);
return true;
},
get width() {
return width;
},
get height() {
return height;
},
/**
* Paint all cells or only dirty columns.
* @param {number} cols
* @param {number} rows
* @param {(col: number, row: number) => number} getColor - returns ABGR uint32
* @param {Iterable<number>} [dirty]
*/
paint(cols, rows, getColor, dirty) {
if (cols < 1 || rows < 1 || width < 1 || height < 1) return;
const { colX, rowOffset } = getGeometry(cols, rows);
if (dirty) {
for (const c of dirty) {
if (c < 0 || c >= cols) continue;
const x0 = colX[c];
const x1 = colX[c + 1];
if (paintColumn(c, rows, x0, x1, rowOffset, getColor)) {
context.putImageData(imageData, 0, 0, x0, 0, x1 - x0, height);
}
}
return;
}
for (let c = 0; c < cols; c++) {
paintColumn(c, rows, colX[c], colX[c + 1], rowOffset, getColor);
}
context.putImageData(imageData, 0, 0);
},
};
}
-87
View File
@@ -1,87 +0,0 @@
#heatmap {
height: 100%;
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
padding: var(--main-padding);
> header {
flex-shrink: 0;
display: flex;
white-space: nowrap;
overflow-x: auto;
padding-bottom: 0.25rem;
margin-bottom: -0.25rem;
padding-left: var(--main-padding);
margin-left: var(--negative-main-padding);
padding-right: var(--main-padding);
margin-right: var(--negative-main-padding);
}
> fieldset {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
align-items: baseline;
flex-shrink: 0;
text-transform: lowercase;
overflow-x: auto;
min-width: 0;
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
margin: 0.5rem var(--negative-main-padding);
padding: 0.5rem var(--main-padding);
gap: 1rem;
@media (max-width: 767px) {
grid-auto-flow: row;
grid-template-columns: repeat(2, max-content);
}
> label {
display: flex;
align-items: baseline;
flex-shrink: 0;
gap: 0.5rem;
cursor: pointer;
color: var(--color);
> span:first-child {
color: var(--off-color);
}
select {
width: auto;
line-height: 1;
}
}
}
> canvas {
background-color: var(--black);
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
display: block;
image-rendering: pixelated;
touch-action: manipulation;
}
> [role="tooltip"] {
position: absolute;
z-index: 1;
max-width: min(18rem, calc(100% - 1rem));
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--background-color);
color: var(--color);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
pointer-events: none;
white-space: pre;
}
}
-32
View File
@@ -1,32 +0,0 @@
const DAY_MS = 86_400_000;
export const GENESIS_DATE = "2009-01-03";
/**
* @param {Date} date
*/
export function toISODate(date) {
return date.toISOString().slice(0, 10);
}
export function todayISODate() {
return toISODate(new Date());
}
/**
* Inclusive UTC date range.
*
* @param {string} from
* @param {string} to
*/
export function dateRange(from, to) {
const dates = [];
for (
let time = Date.parse(`${from}T00:00:00Z`),
end = Date.parse(`${to}T00:00:00Z`);
time <= end;
time += DAY_MS
) {
dates.push(toISODate(new Date(time)));
}
return dates;
}
-38
View File
@@ -1,38 +0,0 @@
import { numberToShortUSFormat } from "../../../scripts/utils/format.js";
/**
* @param {Object} [args]
* @param {string} [args.valueLabel]
* @param {(value: number) => string} [args.formatValue]
* @returns {HeatmapTooltipFn}
*/
export function defaultTooltip({
valueLabel = "Value",
formatValue = formatNumber,
} = {}) {
return ({ option, grid, col, row }) => {
const dateRange = grid.getDateIndexRange(col);
const yRange = grid.getYRange(row);
const value = grid.getValue(col, row);
const yLabel = option.axis?.y?.label ?? "y";
const formatY = option.axis?.y?.format ?? formatNumber;
const date = grid.dates[dateRange.end] ?? grid.dates[dateRange.start] ?? "";
return [
date,
`${capitalize(yLabel)}: ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
`${valueLabel}: ${formatValue(value)}`,
].join("\n");
};
}
/** @param {number} value */
function formatNumber(value) {
return numberToShortUSFormat(value);
}
/** @param {string} value */
function capitalize(value) {
return value ? value[0].toUpperCase() + value.slice(1) : value;
}
-68
View File
@@ -1,68 +0,0 @@
const OFFSET = 12;
const EDGE_PADDING = 8;
/** @typedef {"auto" | "above"} TooltipPlacement */
/**
* @param {HTMLElement} parent
*/
export function createTooltipView(parent) {
const element = document.createElement("div");
element.hidden = true;
element.setAttribute("role", "tooltip");
parent.append(element);
return {
/**
* @param {PointerEvent} event
* @param {string} text
* @param {{ placement?: TooltipPlacement }} [options]
*/
show(event, text, { placement = "auto" } = {}) {
if (element.textContent !== text) element.textContent = text;
element.hidden = false;
place(event, parent, element, placement);
},
hide() {
element.hidden = true;
},
};
}
/**
* @param {PointerEvent} event
* @param {HTMLElement} parent
* @param {HTMLElement} element
* @param {TooltipPlacement} placement
*/
function place(event, parent, element, placement) {
const parentRect = parent.getBoundingClientRect();
const x = event.clientX - parentRect.left;
const y = event.clientY - parentRect.top;
const width = element.offsetWidth;
const height = element.offsetHeight;
let left = placement === "above" ? x - width / 2 : x + OFFSET;
let top = placement === "above" ? y - height - OFFSET : y + OFFSET;
if (left + width + EDGE_PADDING > parentRect.width) {
left = x - width - OFFSET;
}
if (placement === "above" && top < EDGE_PADDING) {
top = y + OFFSET;
} else if (top + height + EDGE_PADDING > parentRect.height) {
top = y - height - OFFSET;
}
element.style.left = `${clamp(left, EDGE_PADDING, parentRect.width - width - EDGE_PADDING)}px`;
element.style.top = `${clamp(top, EDGE_PADDING, parentRect.height - height - EDGE_PADDING)}px`;
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), Math.max(min, max));
}
-61
View File
@@ -1,61 +0,0 @@
/**
* @typedef {Object} HeatmapImplicitPoints
* @property {"implicit"} kind
* @property {number} yStart
* @property {number} yStep
* @property {ArrayLike<number>} values
*
* @typedef {Object} HeatmapExplicitPoints
* @property {"explicit"} kind
* @property {ArrayLike<number>} y
* @property {ArrayLike<number>} values
*
* @typedef {HeatmapImplicitPoints | HeatmapExplicitPoints} HeatmapPoints
*
* @typedef {Object} HeatmapPointSource
* @property {(date: string, signal: AbortSignal, onPoints?: (points: HeatmapPoints) => void) => Promise<HeatmapPoints>} fetch
*
* @typedef {Object} HeatmapRange
* @property {number} start
* @property {number} end
*
* @typedef {"genesis" | "today" | `${number}`} HeatmapDateKey
*
* @typedef {Object} HeatmapDefaults
* @property {HeatmapDateKey} [from]
* @property {HeatmapDateKey} [to]
* @property {number} [yMin]
* @property {number} [yMax]
*
* @typedef {Object} HeatmapGridAddResult
* @property {number} col
* @property {boolean} maxChanged
*
* @typedef {Object} HeatmapGrid
* @property {readonly string[]} dates
* @property {number} cols
* @property {number} rows
* @property {(dateIndex: number, points: HeatmapPoints) => HeatmapGridAddResult | undefined} add
* @property {(col: number, row: number) => number} getValue
* @property {(col: number, row: number) => number} getCount
* @property {(col?: number) => number} getMaxValue
* @property {(col?: number) => number} getMagnitudeMaxValue
* @property {(col: number) => HeatmapRange} getDateIndexRange
* @property {(row: number) => HeatmapRange} getYRange
*
* @typedef {Object} HeatmapGridFactory
* @property {(args: { dates: readonly string[], width: number, height: number, yMin?: number, yMax?: number }) => HeatmapGrid} create
*
* @typedef {Object} HeatmapAxisChoice
* @property {string} label
* @property {string} [key]
* @property {number} value
*
* @typedef {Object} HeatmapAxis
* @property {{ label: string, choices?: HeatmapAxisChoice[], format?: (value: number) => string }} [y]
*
* @typedef {(value: number, context: { grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
* @typedef {(context: { option: { axis?: HeatmapAxis }, grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn
*/
export {};
-280
View File
@@ -1,280 +0,0 @@
import { brk } from "../../scripts/utils/client.js";
import { numberToShortUSFormat } from "../../scripts/utils/format.js";
import { formatCompact } from "./format.js";
import { createAverageGrid } from "./grid.js";
import {
DIVERGING_NEGATIVE_LUT,
DIVERGING_POSITIVE_LUT,
INFERNO_LUT,
divergingPowerIntensityColor,
powerIntensityColor,
} from "./lut.js";
import { defaultTooltip } from "./tooltip/index.js";
/** @typedef {Brk.Cohort} UrpdCohort */
/**
* @typedef {Object} UrpdMetric
* @property {string} name
* @property {string} title
* @property {(bucket: Urpd["buckets"][number]) => number} getValue
* @property {HeatmapColorFn} color
* @property {{ valueLabel: string, formatValue: (value: number) => string }} tooltip
*/
/** @typedef {{ name: string, cohort: UrpdCohort }} UrpdCohortFolder */
const AGGREGATION = "log2000";
const MIN_LOG = -2;
const MAX_LOG = 6;
const DEFAULT_MIN_LOG = Math.log10(1_000);
const DEFAULT_MAX_LOG = Math.log10(250_000);
const PRICE_CHOICES = [
{ label: "$0.01", key: "0.01", value: Math.log10(0.01) },
{ label: "$0.1", key: "0.1", value: Math.log10(0.1) },
{ label: "$1", key: "1", value: 0 },
{ label: "$10", key: "10", value: 1 },
{ label: "$100", key: "100", value: 2 },
{ label: "$250", key: "250", value: Math.log10(250) },
{ label: "$1k", key: "1k", value: Math.log10(1_000) },
{ label: "$2.5k", key: "2.5k", value: Math.log10(2_500) },
{ label: "$5k", key: "5k", value: Math.log10(5_000) },
{ label: "$10k", key: "10k", value: Math.log10(10_000) },
{ label: "$25k", key: "25k", value: Math.log10(25_000) },
{ label: "$50k", key: "50k", value: Math.log10(50_000) },
{ label: "$100k", key: "100k", value: Math.log10(100_000) },
{ label: "$250k", key: "250k", value: Math.log10(250_000) },
{ label: "$500k", key: "500k", value: Math.log10(500_000) },
{ label: "$1M", key: "1M", value: Math.log10(1_000_000) },
];
const VALUE_COLOR = powerIntensityColor(INFERNO_LUT, 0.4);
const PNL_COLOR = divergingPowerIntensityColor(
DIVERGING_NEGATIVE_LUT,
DIVERGING_POSITIVE_LUT,
0.4,
);
/** @type {UrpdMetric[]} */
const METRICS = [
{
name: "supply",
title: "Supply",
getValue: (bucket) => bucket.supply,
color: VALUE_COLOR,
tooltip: {
valueLabel: "Supply",
formatValue: formatBitcoin,
},
},
{
name: "capital",
title: "Capital",
getValue: (bucket) => bucket.realizedCap,
color: VALUE_COLOR,
tooltip: {
valueLabel: "Realized cap",
formatValue: formatDollar,
},
},
{
name: "profitability",
title: "Profitability",
getValue: (bucket) => bucket.unrealizedPnl,
color: PNL_COLOR,
tooltip: {
valueLabel: "Unrealized PnL",
formatValue: formatSignedDollar,
},
},
];
/** @type {UrpdCohortFolder[]} */
const AGE_BANDS = [
{ name: "Up to 1h", cohort: "utxos_under_1h_old" },
{ name: "1h to 1d", cohort: "utxos_1h_to_1d_old" },
{ name: "1d to 1w", cohort: "utxos_1d_to_1w_old" },
{ name: "1w to 1m", cohort: "utxos_1w_to_1m_old" },
{ name: "1m to 2m", cohort: "utxos_1m_to_2m_old" },
{ name: "2m to 3m", cohort: "utxos_2m_to_3m_old" },
{ name: "3m to 4m", cohort: "utxos_3m_to_4m_old" },
{ name: "4m to 5m", cohort: "utxos_4m_to_5m_old" },
{ name: "5m to 6m", cohort: "utxos_5m_to_6m_old" },
{ name: "6m to 1y", cohort: "utxos_6m_to_1y_old" },
{ name: "1y to 2y", cohort: "utxos_1y_to_2y_old" },
{ name: "2y to 3y", cohort: "utxos_2y_to_3y_old" },
{ name: "3y to 4y", cohort: "utxos_3y_to_4y_old" },
{ name: "4y to 5y", cohort: "utxos_4y_to_5y_old" },
{ name: "5y to 6y", cohort: "utxos_5y_to_6y_old" },
{ name: "6y to 7y", cohort: "utxos_6y_to_7y_old" },
{ name: "7y to 8y", cohort: "utxos_7y_to_8y_old" },
{ name: "8y to 10y", cohort: "utxos_8y_to_10y_old" },
{ name: "10y to 12y", cohort: "utxos_10y_to_12y_old" },
{ name: "12y to 15y", cohort: "utxos_12y_to_15y_old" },
{ name: "Over 15y", cohort: "utxos_over_15y_old" },
];
export const urpdAllHeatmapOptions = createCohortHeatmapOptions({
cohort: "all",
});
export const urpdSthHeatmapOptions = createCohortHeatmapOptions({
cohort: "sth",
titlePrefix: "STH",
});
export const urpdLthHeatmapOptions = createCohortHeatmapOptions({
cohort: "lth",
titlePrefix: "LTH",
});
export const urpdAgeBandHeatmapFolders = AGE_BANDS.map(({ name, cohort }) => ({
name,
tree: createCohortHeatmapOptions({ cohort, titlePrefix: name }),
}));
/**
* @param {Object} args
* @param {UrpdCohort} args.cohort
* @param {string} [args.titlePrefix]
* @returns {PartialHeatmapOption[]}
*/
function createCohortHeatmapOptions({ cohort, titlePrefix }) {
return METRICS.map((metric) => {
const title = titlePrefix
? `${titlePrefix} ${metric.title} Distribution`
: `${metric.title} Distribution`;
return createUrpdHeatmapOption({
...metric,
cohort,
title,
});
});
}
/**
* @param {Object} args
* @param {UrpdCohort} args.cohort
* @param {string} args.name
* @param {string} args.title
* @param {(bucket: Urpd["buckets"][number]) => number} args.getValue
* @param {HeatmapColorFn} args.color
* @param {{ valueLabel?: string, formatValue?: (value: number) => string }} args.tooltip
* @returns {PartialHeatmapOption}
*/
function createUrpdHeatmapOption({
cohort,
name,
title,
getValue,
color,
tooltip,
}) {
return {
kind: "heatmap",
name,
title,
points: {
fetch: (date, signal, onPoints) =>
fetchUrpdPoints(cohort, date, signal, getValue, onPoints),
},
grid: createAverageGrid({
yMin: MIN_LOG,
yMax: MAX_LOG,
minCellSize: 2,
}),
color,
axis: {
y: {
label: "price",
choices: PRICE_CHOICES,
format: formatPrice,
},
},
defaults: {
from: "2017",
to: "today",
yMin: DEFAULT_MIN_LOG,
yMax: DEFAULT_MAX_LOG,
},
tooltip: defaultTooltip(tooltip),
};
}
/**
* @param {UrpdCohort} cohort
* @param {string} date
* @param {AbortSignal} signal
* @param {(bucket: Urpd["buckets"][number]) => number} getValue
* @param {(points: HeatmapPoints) => void} [onPoints]
* @returns {Promise<HeatmapPoints>}
*/
async function fetchUrpdPoints(cohort, date, signal, getValue, onPoints) {
/** @type {HeatmapPoints | undefined} */
let points;
const urpd = await brk.getUrpdAt(cohort, date, AGGREGATION, {
signal,
onValue: onPoints
? (urpd) => {
points = toPoints(urpd, getValue);
onPoints(points);
}
: undefined,
});
return points ?? toPoints(urpd, getValue);
}
/**
* @param {Urpd} urpd
* @param {(bucket: Urpd["buckets"][number]) => number} getValue
* @returns {HeatmapPoints}
*/
function toPoints(urpd, getValue) {
const buckets = urpd.buckets;
const y = new Float64Array(buckets.length);
const values = new Float64Array(buckets.length);
let length = 0;
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i];
const pointValue = getValue(bucket);
if (bucket.priceFloor <= 0 || !Number.isFinite(pointValue)) continue;
y[length] = Math.log10(bucket.priceFloor);
values[length] = pointValue;
length++;
}
return {
kind: "explicit",
y: y.subarray(0, length),
values: values.subarray(0, length),
};
}
/** @param {number} value */
function formatPrice(value) {
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.001) {
const choice = PRICE_CHOICES.find((choice) => choice.value === rounded);
if (choice) return choice.label;
}
const price = 10 ** value;
if (price >= 1_000_000) return `$${formatCompact(price / 1_000_000)}M`;
if (price >= 1_000) return `$${formatCompact(price / 1_000)}k`;
return `$${formatCompact(price)}`;
}
/** @param {number} value */
function formatBitcoin(value) {
return `${numberToShortUSFormat(value)} BTC`;
}
/** @param {number} value */
function formatDollar(value) {
return `$${numberToShortUSFormat(value)}`;
}
/** @param {number} value */
function formatSignedDollar(value) {
const formatted = `$${numberToShortUSFormat(Math.abs(value))}`;
if (value > 0) return `+${formatted}`;
if (value < 0) return `-${formatted}`;
return formatted;
}