heatmaps: part 12

This commit is contained in:
nym21
2026-06-01 10:30:44 +02:00
parent 087a3b6fd6
commit a94d31dfdf
10 changed files with 284 additions and 39 deletions
+19 -5
View File
@@ -184,6 +184,23 @@ impl Vecs {
range: Range<usize>,
cap: Option<&Lengths>,
) -> Vec<f64> {
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<IM: StorageMode>(
oracle: &mut Oracle,
indexer: &Indexer<IM>,
range: Range<usize>,
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
}
}
+70 -9
View File
@@ -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<HistogramEmaCompact> {
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<usize>,
safe: &Lengths,
) -> Result<HistogramEma> {
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<usize>) -> impl Iterator<Item = Range<usize>> {
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)]);
}
}
+1 -1
View File
@@ -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"
*
+2 -1
View File
@@ -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<Omit<PartialHeatmapOption, "tooltip">> & Pick<PartialHeatmapOption, "tooltip"> & ProcessedOptionAddons} HeatmapOption
* @typedef {Required<Omit<PartialHeatmapOption, "axis" | "tooltip">> & Pick<PartialHeatmapOption, "axis" | "tooltip"> & ProcessedOptionAddons} HeatmapOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"link"} [kind]
+1 -1
View File
@@ -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,
};
+14 -10
View File
@@ -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 };
},
};
+107 -3
View File
@@ -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)));
+57 -5
View File
@@ -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+$/, "");
}
+4 -2
View File
@@ -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");
};
+9 -2
View File
@@ -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 {};