mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
heatmaps: part 12
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
*
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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+$/, "");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user