mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-17 02:09:44 -07:00
website: redesign part 1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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("-")}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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+$/, "");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user