diff --git a/website/scripts/options/partial.js b/website/scripts/options/partial.js index 31044dfdd..b40016040 100644 --- a/website/scripts/options/partial.js +++ b/website/scripts/options/partial.js @@ -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 }, + ], }, ], }, diff --git a/website/src/heatmap/controls/dates.js b/website/src/heatmap/controls/dates.js index 3241006a0..471fda832 100644 --- a/website/src/heatmap/controls/dates.js +++ b/website/src/heatmap/controls/dates.js @@ -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), ); diff --git a/website/src/heatmap/controls/y.js b/website/src/heatmap/controls/y.js index 3635e4906..fbfe57670 100644 --- a/website/src/heatmap/controls/y.js +++ b/website/src/heatmap/controls/y.js @@ -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); +} diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js deleted file mode 100644 index f5952f3bb..000000000 --- a/website/src/heatmap/demo.js +++ /dev/null @@ -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} - */ -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"); - } -} diff --git a/website/src/heatmap/grid.js b/website/src/heatmap/grid.js index 8f720bc33..12e33b1c9 100644 --- a/website/src/heatmap/grid.js +++ b/website/src/heatmap/grid.js @@ -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) { diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index e19ef9bf6..b0dc55f76 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -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 diff --git a/website/src/heatmap/loader.js b/website/src/heatmap/loader.js index a95505749..c2da1c089 100644 --- a/website/src/heatmap/loader.js +++ b/website/src/heatmap/loader.js @@ -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) { diff --git a/website/src/heatmap/lut.js b/website/src/heatmap/lut.js index 96479970b..c878590c1 100644 --- a/website/src/heatmap/lut.js +++ b/website/src/heatmap/lut.js @@ -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} 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} 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} 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} negativeLut + * @param {ArrayLike} 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} 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} 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]. */ diff --git a/website/src/heatmap/oracle.js b/website/src/heatmap/oracle.js index d287b0444..25974416a 100644 --- a/website/src/heatmap/oracle.js +++ b/website/src/heatmap/oracle.js @@ -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" }, ), }; } diff --git a/website/src/heatmap/tooltip/index.js b/website/src/heatmap/tooltip/index.js index e4ab2e1ec..535457327 100644 --- a/website/src/heatmap/tooltip/index.js +++ b/website/src/heatmap/tooltip/index.js @@ -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"); }; } diff --git a/website/src/heatmap/types.js b/website/src/heatmap/types.js index ba3e05db4..b3a7e96da 100644 --- a/website/src/heatmap/types.js +++ b/website/src/heatmap/types.js @@ -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 diff --git a/website/src/heatmap/urpd.js b/website/src/heatmap/urpd.js index b75735323..f175b3396 100644 --- a/website/src/heatmap/urpd.js +++ b/website/src/heatmap/urpd.js @@ -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} */ -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`;