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
+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 {};