diff --git a/crates/brk_computer/src/prices/compute.rs b/crates/brk_computer/src/prices/compute.rs index f62ca4447..b427d6818 100644 --- a/crates/brk_computer/src/prices/compute.rs +++ b/crates/brk_computer/src/prices/compute.rs @@ -184,6 +184,23 @@ impl Vecs { range: Range, cap: Option<&Lengths>, ) -> Vec { + let mut ref_bins = Vec::with_capacity(range.len()); + Self::feed_blocks_with(oracle, indexer, range, cap, |_, _, ref_bin| { + ref_bins.push(ref_bin); + }); + ref_bins + } + + /// Feed a range of blocks into an Oracle and call `on_block` after each + /// processed block. This lets callers observe derived state such as EMA + /// without duplicating the histogram extraction path. + pub fn feed_blocks_with( + oracle: &mut Oracle, + indexer: &Indexer, + range: Range, + cap: Option<&Lengths>, + mut on_block: impl FnMut(usize, &Oracle, f64), + ) { let (total_txs, total_outputs, height_len) = match cap { Some(c) => ( c.tx_index.to_usize(), @@ -211,8 +228,6 @@ impl Vecs { .first_txout_index .collect_range_at(range.start, collect_end); - let mut ref_bins = Vec::with_capacity(range.len()); - // Cursor avoids per-block PcoVec page decompression for the // tx-indexed first_txout_index lookup. Accessed tx_index values // are strictly increasing across blocks, so it only advances forward. @@ -273,9 +288,8 @@ impl Vecs { }); } - ref_bins.push(oracle.process_histogram(&hist)); + let ref_bin = oracle.process_histogram(&hist); + on_block(range.start + idx, oracle, ref_bin); } - - ref_bins } } diff --git a/crates/brk_query/src/impl/oracle.rs b/crates/brk_query/src/impl/oracle.rs index 3c83d172d..e8fc4acd2 100644 --- a/crates/brk_query/src/impl/oracle.rs +++ b/crates/brk_query/src/impl/oracle.rs @@ -4,7 +4,7 @@ use brk_computer::prices::Vecs as PricesVecs; use brk_error::{Error, Result}; use brk_indexer::Lengths; use brk_oracle::{ - Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, + Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_FAST, cents_to_bin, sats_to_bin, }; use brk_types::{Day1, Dollars, Sats, TxOutIndex}; @@ -37,16 +37,36 @@ impl Query { pub fn confirmed_histogram_ema_day(&self, day: Day1) -> Result { let safe = self.safe_lengths(); let range = self.day_block_range(day, &safe)?; - let count = range.len() as f64; + Ok(self.average_histogram_ema_range(range, &safe)?.to_compact()) + } + + fn average_histogram_ema_range( + &self, + range: Range, + safe: &Lengths, + ) -> Result { + let count = range.len(); let mut acc = HistogramEma::zeros(); - for height in range { - let oracle = self.ema_oracle_at(height, &safe)?; - acc.iter_mut() - .zip(oracle.ema().iter()) - .for_each(|(a, &e)| *a += e); + + for segment in ema_config_segments(range) { + let mut oracle = self.ema_oracle_at(segment.start, safe)?; + add_ema(&mut acc, oracle.ema()); + + let feed_start = segment.start + 1; + if feed_start < segment.end { + PricesVecs::feed_blocks_with( + &mut oracle, + self.indexer(), + feed_start..segment.end, + Some(safe), + |_, oracle, _| add_ema(&mut acc, oracle.ema()), + ); + } } + + let count = count as f64; acc.iter_mut().for_each(|a| *a /= count); - Ok(acc.to_compact()) + Ok(acc) } /// Unfiltered per-bin output counts at the live tip: every forming-block @@ -137,7 +157,7 @@ impl Query { let config = Config::for_height(end.saturating_sub(1)); let start = end.saturating_sub(config.window_size); Oracle::from_checkpoint(seed_bin, config, |o| { - PricesVecs::feed_blocks(o, self.indexer(), start..end, Some(safe)); + PricesVecs::feed_blocks_with(o, self.indexer(), start..end, Some(safe), |_, _, _| {}); }) } @@ -236,3 +256,44 @@ impl Query { hist } } + +fn add_ema(acc: &mut HistogramEma, ema: &HistogramEma) { + acc.iter_mut() + .zip(ema.iter()) + .for_each(|(a, &e)| *a += e); +} + +fn ema_config_segments(range: Range) -> impl Iterator> { + let split = START_HEIGHT_FAST.clamp(range.start, range.end); + [range.start..split, split..range.end] + .into_iter() + .filter(|range| !range.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ema_config_segments_split_at_fast_start() { + let segments: Vec<_> = + ema_config_segments((START_HEIGHT_FAST - 2)..(START_HEIGHT_FAST + 2)).collect(); + assert_eq!( + segments, + vec![ + (START_HEIGHT_FAST - 2)..START_HEIGHT_FAST, + START_HEIGHT_FAST..(START_HEIGHT_FAST + 2), + ] + ); + } + + #[test] + fn ema_config_segments_omits_empty_sides() { + let slow: Vec<_> = ema_config_segments((START_HEIGHT_FAST - 2)..START_HEIGHT_FAST).collect(); + assert_eq!(slow, vec![(START_HEIGHT_FAST - 2)..START_HEIGHT_FAST]); + + let fast: Vec<_> = + ema_config_segments(START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)).collect(); + assert_eq!(fast, vec![START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)]); + } +} diff --git a/website/scripts/_types.js b/website/scripts/_types.js index cd8ad6aa5..01c3b159f 100644 --- a/website/scripts/_types.js +++ b/website/scripts/_types.js @@ -12,7 +12,7 @@ * * @import { Color } from "./utils/colors.js" * - * @import { HeatmapPointSource, HeatmapGridFactory, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js" + * @import { HeatmapAxis, HeatmapPointSource, HeatmapGridFactory, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js" * * @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, ExplorerOption, UrlOption, PartialOptionsGroup, OptionsGroup, PartialOptionsTree, UtxoCohortObject, AddrCohortObject, CohortObject, CohortGroupObject, FetchedLineSeriesBlueprint, FetchedBaselineSeriesBlueprint, FetchedHistogramSeriesBlueprint, FetchedDotsBaselineSeriesBlueprint, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, PatternBasicWithMarketCap, PatternBasicWithoutMarketCap, PatternWithoutRelative, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortBasicWithMarketCap, CohortBasicWithoutMarketCap, CohortWithoutRelative, CohortAddr, CohortLongTerm, CohortAgeRange, CohortAgeRangeWithMatured, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupLongTerm, CohortGroupAgeRange, CohortGroupBasic, CohortGroupBasicWithMarketCap, CohortGroupBasicWithoutMarketCap, CohortGroupWithoutRelative, CohortGroupAddr, UtxoCohortGroupObject, AddrCohortGroupObject, FetchedDotsSeriesBlueprint, HeatmapOption, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js" * diff --git a/website/scripts/options/types.js b/website/scripts/options/types.js index 9f3470892..4f455b1d3 100644 --- a/website/scripts/options/types.js +++ b/website/scripts/options/types.js @@ -110,11 +110,12 @@ * @property {HeatmapPointSource} points * @property {HeatmapGridFactory} grid * @property {HeatmapColorFn} color + * @property {HeatmapAxis} [axis] * @property {HeatmapTooltipFn} [tooltip] * * @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption * - * @typedef {Required> & Pick & ProcessedOptionAddons} HeatmapOption + * @typedef {Required> & Pick & ProcessedOptionAddons} HeatmapOption * * @typedef {Object} PartialUrlOptionSpecific * @property {"link"} [kind] diff --git a/website/src/heatmap/demo.js b/website/src/heatmap/demo.js index daf9fc6f8..518e6403e 100644 --- a/website/src/heatmap/demo.js +++ b/website/src/heatmap/demo.js @@ -18,7 +18,7 @@ export const demoHeatmapOption = { points: { fetch: fetchDemoPoints, }, - grid: createAverageGrid({ yStart: 0, yEnd: 1, nativeRows: ROWS }), + grid: createAverageGrid({ yMin: 0, yMax: 1, nativeRows: ROWS }), color: intensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }), tooltip: defaultTooltip, }; diff --git a/website/src/heatmap/grid.js b/website/src/heatmap/grid.js index 112c37c91..05238ee7a 100644 --- a/website/src/heatmap/grid.js +++ b/website/src/heatmap/grid.js @@ -4,22 +4,24 @@ * Generic date/y binning with average merge semantics. * * @param {Object} args - * @param {number} args.yStart - * @param {number} args.yEnd + * @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({ - yStart, - yEnd, + yMin: defaultYMin, + yMax: defaultYMax, minCellSize = 1, maxCols = Number.POSITIVE_INFINITY, nativeRows = Number.POSITIVE_INFINITY, + yOrigin = "bottom", }) { return { - create({ dates, width, height }) { + create({ dates, width, height, yMin = defaultYMin, yMax = defaultYMax }) { const cols = Math.max( 1, Math.min( @@ -37,7 +39,7 @@ export function createAverageGrid({ const maxByCol = new Float64Array(cols); const cumulativeMaxByCol = new Float64Array(cols); let cumulativeMaxDirty = true; - const ySpan = yEnd - yStart; + const ySpan = yMax - yMin; /** @param {number} dateIndex */ function toCol(dateIndex) { @@ -54,9 +56,10 @@ export function createAverageGrid({ if (!Number.isFinite(y) || !Number.isFinite(ySpan) || ySpan <= 0) { return undefined; } - const t = (y - yStart) / ySpan; + const t = (y - yMin) / ySpan; if (t < 0 || t > 1) return undefined; - return rows - 1 - clamp(Math.floor(t * rows), 0, rows - 1); + const row = clamp(Math.floor(t * rows), 0, rows - 1); + return yOrigin === "top" ? row : rows - 1 - row; } /** @@ -138,8 +141,9 @@ export function createAverageGrid({ }, getYRange(row) { if (row < 0 || row >= rows || ySpan <= 0) return emptyRange(); - const start = yStart + ((rows - row - 1) / rows) * ySpan; - const end = yStart + ((rows - row) / rows) * ySpan; + const index = yOrigin === "top" ? row : rows - row - 1; + const start = yMin + (index / rows) * ySpan; + const end = yMin + ((index + 1) / rows) * ySpan; return { start, end }; }, }; diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index 98a31766a..475e70ebc 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -1,5 +1,5 @@ /** @import { HeatmapOption } from "../../scripts/options/types.js" */ -/** @import { HeatmapGrid, HeatmapPoints } from "./types.js" */ +/** @import { HeatmapAxisChoice, HeatmapGrid, HeatmapPoints } from "./types.js" */ import { createHeader, createSelect } from "../../scripts/utils/dom.js"; import { heatmapElement } from "../../scripts/utils/elements.js"; @@ -24,6 +24,12 @@ let canvas; let headingElement; /** @type {HTMLElement | undefined} */ let statusElement; +/** @type {HTMLElement | undefined} */ +let rangeControlsElement; +/** @type {HTMLElement[]} */ +let dateControlElements = []; +/** @type {HTMLElement[]} */ +let yControlElements = []; /** @type {HeatmapOption | undefined} */ let currentOption; /** @type {HeatmapGrid | undefined} */ @@ -40,6 +46,10 @@ let paintScheduled = false; let initialized = false; let from = yearStartISODate(new Date().getUTCFullYear()); let to = todayISODate(); +/** @type {number | undefined} */ +let yMin; +/** @type {number | undefined} */ +let yMax; /** * Initializes the heatmap pane once for the app lifetime. @@ -77,6 +87,7 @@ export function setOption(option) { if (currentOption !== option) { currentOption = option; pointsByDate = new Map(); + updateYControls(option); if (headingElement) headingElement.textContent = option.title; if (canvas) canvas.removeAttribute("title"); } @@ -216,6 +227,8 @@ function rebuildGrid() { dates: currentDates, width: renderer.width, height: renderer.height, + yMin, + yMax, }); for (let i = 0; i < currentDates.length; i++) { @@ -288,7 +301,12 @@ function updateTooltip(event) { canvas.removeAttribute("title"); return; } - canvas.title = currentOption.tooltip({ grid: currentGrid, col, row }); + canvas.title = currentOption.tooltip({ + option: currentOption, + grid: currentGrid, + col, + row, + }); } /** @@ -309,6 +327,7 @@ function updateStatus(completed, total, failed) { function createRangeControls() { const fieldset = document.createElement("fieldset"); + rangeControlsElement = fieldset; statusElement = document.createElement("small"); @@ -351,11 +370,75 @@ function createRangeControls() { toLabel: rangeChoiceLabel, }); - fieldset.append(fromSelect.element, toSelect.element, statusElement); + dateControlElements = [fromSelect.element, toSelect.element]; + renderRangeControls(); return fieldset; } +/** @param {HeatmapOption} option */ +function updateYControls(option) { + const y = option.axis?.y; + const choices = y?.choices; + if (!choices || choices.length < 2) { + yMin = undefined; + yMax = undefined; + yControlElements = []; + renderRangeControls(); + return; + } + + let minChoice = choices[0]; + let maxChoice = choices.at(-1) ?? choices[0]; + yMin = minChoice.value; + yMax = maxChoice.value; + + const minSelect = createSelect({ + id: "heatmap-y-min", + label: `${y.label} from`, + choices, + initialValue: minChoice, + onChange(choice) { + minChoice = choice; + if (minChoice.value > maxChoice.value) { + maxChoice = minChoice; + maxSelect.set(maxChoice); + } + setYRange(minChoice.value, maxChoice.value); + }, + toKey: axisChoiceKey, + toLabel: axisChoiceLabel, + }); + const maxSelect = createSelect({ + id: "heatmap-y-max", + label: "to", + choices, + initialValue: maxChoice, + onChange(choice) { + maxChoice = choice; + if (minChoice.value > maxChoice.value) { + minChoice = maxChoice; + minSelect.set(minChoice); + } + setYRange(minChoice.value, maxChoice.value); + }, + toKey: axisChoiceKey, + toLabel: axisChoiceLabel, + }); + + yControlElements = [minSelect.element, maxSelect.element]; + renderRangeControls(); +} + +function renderRangeControls() { + if (!rangeControlsElement || !statusElement) return; + rangeControlsElement.replaceChildren( + ...dateControlElements, + ...yControlElements, + statusElement, + ); +} + /** * @param {number} currentYear * @returns {RangeChoice[]} @@ -409,6 +492,27 @@ function setRange(nextFrom, nextTo) { loadRange(); } +/** + * @param {number} nextYMin + * @param {number} nextYMax + */ +function setYRange(nextYMin, nextYMax) { + yMin = nextYMin; + yMax = nextYMax; + if (canvas) canvas.removeAttribute("title"); + rebuildGrid(); +} + +/** @param {HeatmapAxisChoice} choice */ +function axisChoiceKey(choice) { + return String(choice.value); +} + +/** @param {HeatmapAxisChoice} choice */ +function axisChoiceLabel(choice) { + return choice.label; +} + /** @param {number} year */ function yearStartISODate(year) { return toISODate(new Date(Date.UTC(year, 0, 1))); diff --git a/website/src/heatmap/oracle.js b/website/src/heatmap/oracle.js index 6aca8a52d..b4667e805 100644 --- a/website/src/heatmap/oracle.js +++ b/website/src/heatmap/oracle.js @@ -9,7 +9,21 @@ import { defaultTooltip } from "./tooltip.js"; const BINS = 2400; const MIN_LOG = -8; const BINS_PER_DECADE = 200; -const MAX_LOG = MIN_LOG + (BINS - 1) / BINS_PER_DECADE; +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 }, +]; export const oracleRawHeatmapOption = createOracleHeatmapOption("raw", "Raw"); export const oracleEmaHeatmapOption = createOracleHeatmapOption("ema", "EMA"); @@ -29,11 +43,19 @@ function createOracleHeatmapOption(mode, name) { fetchOraclePoints(mode, date, signal, onPoints), }, grid: createAverageGrid({ - yStart: MIN_LOG, - yEnd: MIN_LOG + BINS / BINS_PER_DECADE, + yMin: MIN_LOG, + yMax: MIN_LOG + BINS / BINS_PER_DECADE, nativeRows: BINS, + yOrigin: "top", }), color: logIntensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }), + axis: { + y: { + label: "amount", + choices: AMOUNT_CHOICES, + format: formatAmount, + }, + }, tooltip: defaultTooltip, }; } @@ -78,8 +100,38 @@ function fetchOracleValues(mode, date, signal, onValue) { function toOraclePoints(values) { return { kind: "implicit", - yStart: MAX_LOG, - yStep: -1 / BINS_PER_DECADE, + 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`; +} + +/** @param {number} value */ +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+$/, ""); +} diff --git a/website/src/heatmap/tooltip.js b/website/src/heatmap/tooltip.js index 49219e5b2..fb8cc378f 100644 --- a/website/src/heatmap/tooltip.js +++ b/website/src/heatmap/tooltip.js @@ -3,10 +3,12 @@ import { numberToShortUSFormat } from "../../scripts/utils/format.js"; /** @satisfies {HeatmapTooltipFn} */ -export const defaultTooltip = ({ grid, col, row }) => { +export const defaultTooltip = ({ 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 from = grid.dates[dateRange.start] ?? ""; const to = grid.dates[dateRange.end] ?? from; @@ -14,7 +16,7 @@ export const defaultTooltip = ({ grid, col, row }) => { return [ date, - `y ${formatNumber(yRange.start)} to ${formatNumber(yRange.end)}`, + `${yLabel} ${formatY(yRange.start)} to ${formatY(yRange.end)}`, `value ${formatNumber(value)}`, ].join("\n"); }; diff --git a/website/src/heatmap/types.js b/website/src/heatmap/types.js index 4cc6f0254..ea1c0fcab 100644 --- a/website/src/heatmap/types.js +++ b/website/src/heatmap/types.js @@ -30,10 +30,17 @@ * @property {(row: number) => HeatmapRange} getYRange * * @typedef {Object} HeatmapGridFactory - * @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create + * @property {(args: { dates: readonly string[], width: number, height: number, yMin?: number, yMax?: number }) => HeatmapGrid} create + * + * @typedef {Object} HeatmapAxisChoice + * @property {string} label + * @property {number} value + * + * @typedef {Object} HeatmapAxis + * @property {{ label: string, choices?: HeatmapAxisChoice[], format?: (value: number) => string }} [y] * * @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn - * @typedef {(context: { grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn + * @typedef {(context: { option: { axis?: HeatmapAxis }, grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn */ export {};