mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-19 03:09:44 -07:00
heatmaps: part 12
This commit is contained in:
@@ -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