heatmaps: part 22

This commit is contained in:
nym21
2026-06-01 17:54:42 +02:00
parent 88c38e74f9
commit 2bbc535b58
12 changed files with 448 additions and 178 deletions
+14 -6
View File
@@ -25,12 +25,16 @@ import { createNetworkSection } from "./network.js";
import { createMiningSection } from "./mining.js";
import { createCointimeSection } from "./cointime.js";
import { createInvestingSection } from "./investing.js";
import { demoHeatmapOption } from "../../src/heatmap/demo.js";
import {
oracleOutputsHeatmapOption,
oraclePaymentsHeatmapOption,
} from "../../src/heatmap/oracle.js";
import { urpdSupplyHeatmapOption } from "../../src/heatmap/urpd.js";
import {
urpdAgeBandHeatmapFolders,
urpdAllHeatmapOptions,
urpdLthHeatmapOptions,
urpdSthHeatmapOptions,
} from "../../src/heatmap/urpd.js";
// Re-export types for external consumers
export * from "./types.js";
@@ -305,14 +309,18 @@ export function createPartialOptions() {
{
name: "Heatmaps",
tree: [
demoHeatmapOption,
{
name: "oracle histograms",
name: "Output Values",
tree: [oracleOutputsHeatmapOption, oraclePaymentsHeatmapOption],
},
{
name: "URPD",
tree: [urpdSupplyHeatmapOption],
name: "Price Distributions",
tree: [
...urpdAllHeatmapOptions,
{ name: "STH", tree: urpdSthHeatmapOptions },
{ name: "LTH", tree: urpdLthHeatmapOptions },
{ name: "Age Bands", tree: urpdAgeBandHeatmapFolders },
],
},
],
},
+2 -2
View File
@@ -34,13 +34,13 @@ export function createDateControls(option, onChange) {
const persistedFrom = createHeatmapPersistedValue(
option,
"from",
"hm_from",
"from",
rangeChoiceLabel(defaultFromChoice),
);
const persistedTo = createHeatmapPersistedValue(
option,
"to",
"hm_to",
"to",
rangeChoiceLabel(defaultToChoice),
);
+10 -5
View File
@@ -18,24 +18,24 @@ export function createYControls(option, onChange) {
choices,
String(option.defaults?.yMin ?? ""),
fallbackMinChoice,
axisChoiceKey,
axisChoiceValueKey,
);
const defaultMaxChoice = findChoiceByKey(
choices,
String(option.defaults?.yMax ?? ""),
fallbackMaxChoice,
axisChoiceKey,
axisChoiceValueKey,
);
const persistedMin = createHeatmapPersistedValue(
option,
"y-min",
"hm_y_min",
"min",
axisChoiceKey(defaultMinChoice),
);
const persistedMax = createHeatmapPersistedValue(
option,
"y-max",
"hm_y_max",
"max",
axisChoiceKey(defaultMaxChoice),
);
@@ -105,10 +105,15 @@ export function createYControls(option, onChange) {
/** @param {HeatmapAxisChoice} choice */
function axisChoiceKey(choice) {
return String(choice.value);
return choice.key ?? choice.label;
}
/** @param {HeatmapAxisChoice} choice */
function axisChoiceLabel(choice) {
return choice.label;
}
/** @param {HeatmapAxisChoice} choice */
function axisChoiceValueKey(choice) {
return String(choice.value);
}
-65
View File
@@ -1,65 +0,0 @@
import { createAverageGrid } from "./grid.js";
import { INFERNO_LUT, intensityColor } from "./lut.js";
import { GENESIS_DATE, todayISODate } from "./time.js";
import { defaultTooltip } from "./tooltip/index.js";
const ROWS = 160;
const DAY_MS = 86_400_000;
const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`);
/** @satisfies {PartialHeatmapOption} */
export const demoHeatmapOption = {
kind: "heatmap",
name: "Demo",
title: "Heatmap Demo",
points: {
fetch: fetchDemoPoints,
},
grid: createAverageGrid({ yMin: 0, yMax: 1, nativeRows: ROWS }),
color: intensityColor(INFERNO_LUT),
tooltip: defaultTooltip(),
};
/**
* @param {string} date
* @param {AbortSignal} signal
* @returns {Promise<HeatmapPoints>}
*/
async function fetchDemoPoints(date, signal) {
throwIfAborted(signal);
const values = new Float32Array(ROWS);
const endTime = Date.parse(`${todayISODate()}T00:00:00Z`);
const time = Date.parse(`${date}T00:00:00Z`);
const x = Math.min(
1,
Math.max(
0,
(time - GENESIS_TIME) / Math.max(DAY_MS, endTime - GENESIS_TIME),
),
);
for (let row = 0; row < ROWS; row++) {
const y = row / (ROWS - 1);
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
const blob = Math.exp(
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
);
const floor = x * 0.18 + (1 - y) * 0.12;
values[row] = Math.min(1, Math.max(0, ridge * 0.65 + blob * 0.45 + floor));
}
return {
kind: "implicit",
yStart: 0,
yStep: 1 / (ROWS - 1),
values,
};
}
/** @param {AbortSignal} signal */
function throwIfAborted(signal) {
if (signal.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
}
+27 -4
View File
@@ -35,7 +35,9 @@ export function createAverageGrid({
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 */
@@ -77,14 +79,22 @@ export function createAverageGrid({
/** @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]) max = Math.max(max, sums[index] / counts[index]);
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]);
}
}
@@ -117,8 +127,14 @@ export function createAverageGrid({
}
if (!dirty) return undefined;
const previousMax = maxValue;
const previousMagnitudeMax = magnitudeMaxValue;
updateColumnMax(col);
return { col, maxChanged: maxValue !== previousMax };
return {
col,
maxChanged:
maxValue !== previousMax ||
magnitudeMaxValue !== previousMagnitudeMax,
};
},
getValue(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) {
@@ -131,8 +147,15 @@ export function createAverageGrid({
if (col < 0 || col >= cols || row < 0 || row >= rows) return 0;
return counts[row * cols + col];
},
getMaxValue() {
return maxValue;
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) {
+39 -6
View File
@@ -53,7 +53,7 @@ export function init() {
yMin = range.yMin;
yMax = range.yMax;
hideTooltip();
rebuildGrid();
rebuildAndLoadVisibleDates();
},
});
@@ -102,12 +102,18 @@ export function setOption(option) {
function resizeAndRebuild() {
if (!canvas || !renderer) return;
const { width, height } = canvas.getBoundingClientRect();
if (renderer.resize(width, height)) rebuildGrid();
if (renderer.resize(width, height)) rebuildAndLoadVisibleDates();
}
function loadRange() {
if (!currentOption || !loader) return;
loader.load({ option: currentOption, from, to });
loader.setRange({ option: currentOption, from, to });
rebuildAndLoadVisibleDates();
}
function rebuildAndLoadVisibleDates() {
rebuildGrid();
loadVisibleDates();
}
function rebuildGrid() {
@@ -132,14 +138,41 @@ function rebuildGrid() {
yMax,
});
for (let i = 0; i < dates.length; i++) {
const points = loader.getPoint(dates[i]);
if (points) currentGrid.add(i, points);
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
+22 -6
View File
@@ -37,23 +37,41 @@ export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
* @param {string} args.from
* @param {string} args.to
*/
load({ option, from, 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;
dates = dateRange(from, to);
/** @type {{ date: string, dateIndex: number }[]} */
const missing = [];
for (let dateIndex = 0; dateIndex < dates.length; dateIndex++) {
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) {
rebuildGrid();
abortController = undefined;
return;
}
@@ -90,8 +108,6 @@ export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
}
});
rebuildGrid();
void Promise.all(workers).then(() => {
if (isCurrentLoad(option, controller, currentGeneration)) {
if (needsRebuild) {
+106 -1
View File
@@ -12,6 +12,25 @@ const INFERNO_STOPS = [
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}
@@ -31,7 +50,7 @@ export function intensityColor(lut) {
export function logIntensityColor(lut) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const max = context.grid.getMaxValue();
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)));
@@ -39,6 +58,92 @@ export function logIntensityColor(lut) {
};
}
/**
* @param {ArrayLike<number>} lut
* @returns {HeatmapColorFn}
*/
export function linearIntensityColor(lut) {
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.min(1, value / cap);
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 {ArrayLike<number>} lut
* @param {{ knee?: number, max?: number }} [options]
* @returns {HeatmapColorFn}
*/
export function softIntensityColor(lut, { knee = 0.15, max = 1 } = {}) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const cap = context.grid.getMaxValue(context.col);
if (cap <= 0) return 0x00000000;
const ratio = Math.min(1, value / cap);
const t = (ratio / (ratio + knee)) * max;
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {ArrayLike<number>} lut
* @returns {HeatmapColorFn}
*/
export function smoothLogIntensityColor(lut) {
return (value, context) => {
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
const cap = context.grid.getMaxValue(context.col);
if (cap <= 0) return 0x00000000;
const u = Math.log1p(value) / Math.log1p(cap);
const t = u * u * (3 - 2 * u);
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {number[][]} stops - Tuples of [position, red, green, blue].
*/
+18 -18
View File
@@ -7,28 +7,28 @@ const BINS = 2400;
const MIN_LOG = -8;
const BINS_PER_DECADE = 200;
const AMOUNT_CHOICES = [
{ label: "1 sat", value: -8 },
{ label: "10 sats", value: -7 },
{ label: "100 sats", value: -6 },
{ label: "1k sats", value: -5 },
{ label: "10k sats", value: -4 },
{ label: "100k sats", value: -3 },
{ label: "0.01 BTC", value: -2 },
{ label: "0.1 BTC", value: -1 },
{ label: "1 BTC", value: 0 },
{ label: "10 BTC", value: 1 },
{ label: "100 BTC", value: 2 },
{ label: "1k BTC", value: 3 },
{ label: "10k BTC", value: 4 },
{ 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",
"outputs",
"All",
);
export const oraclePaymentsHeatmapOption = createOracleHeatmapOption(
"payments",
"payments",
"Payments",
);
/**
@@ -41,7 +41,7 @@ function createOracleHeatmapOption(mode, name) {
kind: "heatmap",
name,
title:
mode === "outputs" ? "Output Value Histogram" : "Payment Value Histogram",
mode === "outputs" ? "All Output Values" : "Payment Output Values",
points: {
fetch: (date, signal, onPoints) =>
fetchOraclePoints(mode, date, signal, onPoints),
@@ -74,8 +74,8 @@ function createOracleHeatmapOption(mode, name) {
},
tooltip: defaultTooltip(
mode === "outputs"
? { valueLabel: "Outputs", averageLabel: "Avg outputs" }
: { valueLabel: "Payment signal", averageLabel: "Avg payment signal" },
? { valueLabel: "Outputs" }
: { valueLabel: "Payment signal" },
),
};
}
+2 -7
View File
@@ -3,13 +3,11 @@ import { numberToShortUSFormat } from "../../../scripts/utils/format.js";
/**
* @param {Object} [args]
* @param {string} [args.valueLabel]
* @param {string} [args.averageLabel]
* @param {(value: number) => string} [args.formatValue]
* @returns {HeatmapTooltipFn}
*/
export function defaultTooltip({
valueLabel = "Value",
averageLabel = "Avg value",
formatValue = formatNumber,
} = {}) {
return ({ option, grid, col, row }) => {
@@ -18,16 +16,13 @@ export function defaultTooltip({
const value = grid.getValue(col, row);
const yLabel = option.axis?.y?.label ?? "y";
const formatY = option.axis?.y?.format ?? formatNumber;
const label = grid.getCount(col, row) > 1 ? averageLabel : valueLabel;
const from = grid.dates[dateRange.start] ?? "";
const to = grid.dates[dateRange.end] ?? from;
const date = from === to ? from : `${from} to ${to}`;
const date = grid.dates[dateRange.end] ?? grid.dates[dateRange.start] ?? "";
return [
date,
`${capitalize(yLabel)}: ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
`${label}: ${formatValue(value)}`,
`${valueLabel}: ${formatValue(value)}`,
].join("\n");
};
}
+3 -1
View File
@@ -38,7 +38,8 @@
* @property {(dateIndex: number, points: HeatmapPoints) => HeatmapGridAddResult | undefined} add
* @property {(col: number, row: number) => number} getValue
* @property {(col: number, row: number) => number} getCount
* @property {() => number} getMaxValue
* @property {(col?: number) => number} getMaxValue
* @property {(col?: number) => number} getMagnitudeMaxValue
* @property {(col: number) => HeatmapRange} getDateIndexRange
* @property {(row: number) => HeatmapRange} getYRange
*
@@ -47,6 +48,7 @@
*
* @typedef {Object} HeatmapAxisChoice
* @property {string} label
* @property {string} [key]
* @property {number} value
*
* @typedef {Object} HeatmapAxis
+205 -57
View File
@@ -1,96 +1,230 @@
import { brk } from "../../scripts/utils/client.js";
import { numberToShortUSFormat } from "../../scripts/utils/format.js";
import { createAverageGrid } from "./grid.js";
import { INFERNO_LUT, logIntensityColor } from "./lut.js";
import {
DIVERGING_NEGATIVE_LUT,
DIVERGING_POSITIVE_LUT,
INFERNO_LUT,
divergingPowerIntensityColor,
powerIntensityColor,
} from "./lut.js";
import { defaultTooltip } from "./tooltip/index.js";
const COHORT = "all";
const AGGREGATION = "raw";
/** @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", value: Math.log10(0.01) },
{ label: "$0.1", value: Math.log10(0.1) },
{ label: "$1", value: 0 },
{ label: "$10", value: 1 },
{ label: "$100", value: 2 },
{ label: "$250", value: Math.log10(250) },
{ label: "$1k", value: Math.log10(1_000) },
{ label: "$2.5k", value: Math.log10(2_500) },
{ label: "$5k", value: Math.log10(5_000) },
{ label: "$10k", value: Math.log10(10_000) },
{ label: "$25k", value: Math.log10(25_000) },
{ label: "$50k", value: Math.log10(50_000) },
{ label: "$100k", value: Math.log10(100_000) },
{ label: "$250k", value: Math.log10(250_000) },
{ label: "$500k", value: Math.log10(500_000) },
{ label: "$1M", value: Math.log10(1_000_000) },
{ 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,
);
/** @satisfies {PartialHeatmapOption} */
export const urpdSupplyHeatmapOption = {
kind: "heatmap",
name: "Supply",
title: "URPD Supply",
points: {
fetch: (date, signal, onPoints) =>
fetchUrpdSupplyPoints(date, signal, onPoints),
},
grid: createAverageGrid({
yMin: MIN_LOG,
yMax: MAX_LOG,
}),
color: logIntensityColor(INFERNO_LUT),
axis: {
y: {
label: "price",
choices: PRICE_CHOICES,
format: formatPrice,
/** @type {UrpdMetric[]} */
const METRICS = [
{
name: "supply",
title: "Supply",
getValue: (bucket) => bucket.supply,
color: VALUE_COLOR,
tooltip: {
valueLabel: "Supply",
formatValue: formatBitcoin,
},
},
defaults: {
from: "2017",
to: "today",
yMin: DEFAULT_MIN_LOG,
yMax: DEFAULT_MAX_LOG,
{
name: "capital",
title: "Capital",
getValue: (bucket) => bucket.realizedCap,
color: VALUE_COLOR,
tooltip: {
valueLabel: "Realized cap",
formatValue: formatDollar,
},
},
tooltip: defaultTooltip({
valueLabel: "Supply",
averageLabel: "Avg supply",
formatValue: formatBitcoin,
}),
};
{
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,
}),
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 fetchUrpdSupplyPoints(date, signal, onPoints) {
async function fetchUrpdPoints(cohort, date, signal, getValue, onPoints) {
/** @type {HeatmapPoints | undefined} */
let points;
const urpd = await brk.getUrpdAt(COHORT, date, AGGREGATION, {
const urpd = await brk.getUrpdAt(cohort, date, AGGREGATION, {
signal,
cache: false,
onValue: onPoints
? (value) => {
points = toSupplyPoints(value);
? (urpd) => {
points = toPoints(urpd, getValue);
onPoints(points);
}
: undefined,
});
return points ?? toSupplyPoints(urpd);
return points ?? toPoints(urpd, getValue);
}
/**
* @param {Urpd} urpd
* @param {(bucket: Urpd["buckets"][number]) => number} getValue
* @returns {HeatmapPoints}
*/
function toSupplyPoints(urpd) {
function toPoints(urpd, getValue) {
const buckets = urpd.buckets;
const y = new Float64Array(buckets.length);
const values = new Float64Array(buckets.length);
@@ -98,9 +232,10 @@ function toSupplyPoints(urpd) {
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i];
if (bucket.priceFloor <= 0 || !Number.isFinite(bucket.supply)) continue;
const pointValue = getValue(bucket);
if (bucket.priceFloor <= 0 || !Number.isFinite(pointValue)) continue;
y[length] = Math.log10(bucket.priceFloor);
values[length] = bucket.supply;
values[length] = pointValue;
length++;
}
@@ -130,6 +265,19 @@ 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;
}
/** @param {number} value */
function formatCompact(value) {
if (value >= 1000) return `${formatNumber(value / 1000)}k`;