heatmaps: part 4

This commit is contained in:
nym21
2026-05-30 11:36:49 +02:00
parent e43b53b429
commit cc8fde59e8
11 changed files with 688 additions and 78 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
*
* @import { Color } from "./utils/colors.js"
*
* @import { HeatmapDataSource, HeatmapCells, HeatmapColorFn, HeatmapTooltipFn } from "../src/heatmap/types.js"
* @import { HeatmapPointSource, HeatmapCells, 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"
*
+6 -2
View File
@@ -8,7 +8,10 @@ import {
} from "./panes/chart.js";
import { init as initExplorer } from "./explorer/index.js";
import { init as initSearch } from "./panes/search.js";
import { init as initHeatmap } from "../src/heatmap/index.js";
import {
init as initHeatmap,
setOption as setHeatmapOption,
} from "../src/heatmap/index.js";
import { readStored, removeStored, writeToStorage } from "./utils/storage.js";
import {
asideElement,
@@ -153,9 +156,10 @@ function initSelected() {
element = heatmapElement;
if (firstTimeLoadingHeatmap) {
initHeatmap(option);
initHeatmap();
}
firstTimeLoadingHeatmap = false;
setHeatmapOption(option);
break;
}
+2 -7
View File
@@ -25,6 +25,7 @@ import { createNetworkSection } from "./network.js";
import { createMiningSection } from "./mining.js";
import { createCointimeSection } from "./cointime.js";
import { createInvestingSection } from "./investing.js";
import { demoHeatmapOption } from "../../src/heatmap/demo.js";
// Re-export types for external consumers
export * from "./types.js";
@@ -298,13 +299,7 @@ export function createPartialOptions() {
{
name: "Heatmaps",
tree: [
{
kind: "heatmap",
name: "Demo",
title: "Heatmap Demo",
},
],
tree: [demoHeatmapOption],
},
{
+2 -2
View File
@@ -107,14 +107,14 @@
* @typedef {Object} PartialHeatmapOptionSpecific
* @property {"heatmap"} kind
* @property {string} title
* @property {HeatmapDataSource} data
* @property {HeatmapPointSource} points
* @property {HeatmapCells} cells
* @property {HeatmapColorFn} color
* @property {HeatmapTooltipFn} [tooltip]
*
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
*
* @typedef {Required<PartialHeatmapOption> & ProcessedOptionAddons} HeatmapOption
* @typedef {Required<Omit<PartialHeatmapOption, "tooltip">> & Pick<PartialHeatmapOption, "tooltip"> & ProcessedOptionAddons} HeatmapOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"link"} [kind]
+136
View File
@@ -0,0 +1,136 @@
/** @import { HeatmapCells, HeatmapGrid, HeatmapRange } from "./types.js" */
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/**
* Generic date/y binning with average merge semantics.
*
* @param {Object} args
* @param {number} args.yStart
* @param {number} args.yEnd
* @param {number} [args.minCellSize]
* @param {number} [args.maxCols]
* @param {number} [args.nativeRows]
* @returns {HeatmapCells}
*/
export function createAverageCells({
yStart,
yEnd,
minCellSize = 1,
maxCols = Number.POSITIVE_INFINITY,
nativeRows = Number.POSITIVE_INFINITY,
}) {
return {
create({ dates, width, height }) {
const cols = Math.max(
1,
Math.min(
dates.length || 1,
maxCols,
Math.floor(width / minCellSize) || 1,
),
);
const rows = Math.max(
1,
Math.min(nativeRows, Math.floor(height / minCellSize) || 1),
);
const sums = new Float64Array(cols * rows);
const counts = new Uint32Array(cols * rows);
const ySpan = yEnd - yStart;
/** @param {number} dateIndex */
function toCol(dateIndex) {
if (dateIndex < 0 || dateIndex >= dates.length) return undefined;
return clamp(Math.floor((dateIndex * cols) / dates.length), 0, cols - 1);
}
/** @param {number} y */
function toRow(y) {
if (!Number.isFinite(y) || !Number.isFinite(ySpan) || ySpan <= 0) {
return undefined;
}
const t = (y - yStart) / ySpan;
if (t < 0 || t > 1) return undefined;
return rows - 1 - clamp(Math.floor(t * rows), 0, rows - 1);
}
/**
* @param {number} dateIndex
* @param {number} y
* @param {number} value
*/
function addValue(dateIndex, y, value) {
if (!Number.isFinite(value)) return undefined;
const col = toCol(dateIndex);
const row = toRow(y);
if (col === undefined || row === undefined) return undefined;
const index = row * cols + col;
sums[index] += value;
counts[index] += 1;
return col;
}
/** @type {HeatmapGrid} */
const grid = {
dates,
cols,
rows,
add(dateIndex, points) {
let dirty;
if (points.kind === "implicit") {
for (let i = 0; i < points.values.length; i++) {
const col = addValue(
dateIndex,
points.yStart + i * points.yStep,
points.values[i],
);
dirty = col ?? dirty;
}
} else {
const length = Math.min(points.y.length, points.values.length);
for (let i = 0; i < length; i++) {
const col = addValue(dateIndex, points.y[i], points.values[i]);
dirty = col ?? dirty;
}
}
return dirty;
},
getValue(col, row) {
if (col < 0 || col >= cols || row < 0 || row >= rows) {
return Number.NaN;
}
const index = row * cols + col;
return counts[index] ? sums[index] / counts[index] : Number.NaN;
},
getDateIndexRange(col) {
if (col < 0 || col >= cols || dates.length === 0) {
return emptyRange();
}
const start = Math.floor((col * dates.length) / cols);
const end = Math.floor(((col + 1) * dates.length - 1) / cols);
return { start, end: clamp(end, start, dates.length - 1) };
},
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;
return { start, end };
},
};
return grid;
},
};
}
/** @returns {HeatmapRange} */
function emptyRange() {
return { start: Number.NaN, end: Number.NaN };
}
+58 -21
View File
@@ -1,29 +1,66 @@
import { INFERNO_LUT } from "./lut.js";
/** @import { PartialHeatmapOption } from "../../scripts/options/types.js" */
/** @import { HeatmapPoints } from "./types.js" */
const COLS = 100;
const ROWS = 100;
import { createAverageCells } from "./cells.js";
import { INFERNO_LUT, intensityColor } from "./lut.js";
import { GENESIS_DATE, todayISODate } from "./time.js";
export const demoSource = {
cols: COLS,
rows: ROWS,
getColor,
const ROWS = 160;
const DAY_MS = 86_400_000;
const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`);
/** @satisfies {PartialHeatmapOption} */
export const demoHeatmapOption = {
kind: "heatmap",
name: "Demo",
title: "Heatmap Demo",
points: {
fetch: fetchDemoPoints,
},
cells: createAverageCells({ yStart: 0, yEnd: 1, nativeRows: ROWS }),
color: intensityColor({ light: INFERNO_LUT, dark: INFERNO_LUT }),
};
/**
* @param {number} col
* @param {number} row
* @param {string} date
* @param {AbortSignal} signal
* @returns {Promise<HeatmapPoints>}
*/
function getColor(col, row) {
const x = col / (COLS - 1);
const y = row / (ROWS - 1);
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
const blob = Math.exp(
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
async function fetchDemoPoints(date, signal) {
throwIfAborted(signal);
const values = new Float32Array(ROWS);
const endTime = Date.parse(`${todayISODate()}T00:00:00Z`);
const time = Date.parse(`${date}T00:00:00Z`);
const x = Math.min(
1,
Math.max(
0,
(time - GENESIS_TIME) / Math.max(DAY_MS, endTime - GENESIS_TIME),
),
);
const floor = x * 0.18 + (1 - y) * 0.12;
const i = Math.min(
255,
Math.max(0, ((ridge * 0.65 + blob * 0.45 + floor) * 255) | 0),
);
return INFERNO_LUT[i];
for (let row = 0; row < ROWS; row++) {
const y = row / (ROWS - 1);
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
const blob = Math.exp(
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
);
const floor = x * 0.18 + (1 - y) * 0.12;
values[row] = Math.min(1, Math.max(0, ridge * 0.65 + blob * 0.45 + floor));
}
return {
kind: "implicit",
yStart: 0,
yStep: 1 / (ROWS - 1),
values,
};
}
/** @param {AbortSignal} signal */
function throwIfAborted(signal) {
if (signal.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
}
+370 -24
View File
@@ -1,41 +1,387 @@
import { createHeader } from "../../scripts/utils/dom.js";
/** @import { HeatmapOption } from "../../scripts/options/types.js" */
/** @import { HeatmapGrid, HeatmapPoints } from "./types.js" */
import { createHeader, createSelect } from "../../scripts/utils/dom.js";
import { heatmapElement } from "../../scripts/utils/elements.js";
import { debounce, next } from "../../scripts/utils/timing.js";
import { demoSource } from "./demo.js";
import { dark, onChange as onThemeChange } from "../../scripts/utils/theme.js";
import { createRenderer } from "./renderer.js";
import { dateRange, GENESIS_DATE, todayISODate, toISODate } from "./time.js";
/**
* @typedef {Object} RangeChoice
* @property {string} label
* @property {string} date
*/
const MAX_PARALLEL_FETCHES = 8;
/** @type {ReturnType<typeof createRenderer> | undefined} */
let renderer;
/** @type {HTMLCanvasElement | undefined} */
let canvas;
/** @type {HTMLHeadingElement | undefined} */
let headingElement;
/** @type {HTMLElement | undefined} */
let statusElement;
/** @type {HeatmapOption | undefined} */
let currentOption;
/** @type {HeatmapGrid | undefined} */
let currentGrid;
/** @type {string[]} */
let currentDates = [];
/** @type {Map<string, number>} */
let currentDateIndex = new Map();
/** @type {Map<string, HeatmapPoints>} */
let pointsByDate = new Map();
/** @type {AbortController | undefined} */
let abortController;
let loadGeneration = 0;
let initialized = false;
let from = GENESIS_DATE;
let to = todayISODate();
/**
* Initializes the heatmap pane once for the app lifetime.
*
* @param {HeatmapOption} option
*/
export async function init(option) {
const { headerElement, headingElement } = createHeader();
headingElement.innerHTML = option.title;
export function init() {
if (initialized) return;
initialized = true;
const header = createHeader();
headingElement = header.headingElement;
const { headerElement } = header;
heatmapElement.append(headerElement);
heatmapElement.append(createRangeControls());
const canvas = document.createElement("canvas");
canvas = document.createElement("canvas");
heatmapElement.append(canvas);
await next();
renderer = createRenderer(canvas);
let renderer = createRenderer(canvas);
let source = demoSource;
canvas.addEventListener("mousemove", updateTooltip);
canvas.addEventListener("mouseleave", () => canvas?.removeAttribute("title"));
onThemeChange(paint);
function render() {
renderer.paint(source.cols, source.rows, source.getColor);
}
const { width, height } = canvas.getBoundingClientRect();
if (renderer.resize(width, height)) {
render();
}
void next().then(resizeAndRebuild);
new ResizeObserver(
debounce(() => {
const { width, height } = canvas.getBoundingClientRect();
if (renderer.resize(width, height)) {
render();
}
}, 1000),
resizeAndRebuild();
}, 250),
).observe(heatmapElement);
}
/** @param {HeatmapOption} option */
export function setOption(option) {
init();
if (currentOption !== option) {
currentOption = option;
pointsByDate = new Map();
if (headingElement) headingElement.textContent = option.title;
if (canvas) canvas.removeAttribute("title");
}
loadRange();
}
function resizeAndRebuild() {
if (!canvas || !renderer) return;
const { width, height } = canvas.getBoundingClientRect();
if (renderer.resize(width, height)) rebuildGrid();
}
function loadRange() {
if (!currentOption) return;
abortController?.abort();
const generation = ++loadGeneration;
const option = currentOption;
const controller = new AbortController();
abortController = controller;
currentDates = dateRange(from, to);
currentDateIndex = createDateIndex(currentDates);
rebuildGrid();
const missing = currentDates.filter((date) => !pointsByDate.has(date));
let completed = currentDates.length - missing.length;
let failed = 0;
updateStatus(completed, currentDates.length, failed);
if (!missing.length) return;
let cursor = 0;
const workers = Array.from({
length: Math.min(MAX_PARALLEL_FETCHES, missing.length),
}).map(async () => {
let index = nextMissingIndex();
while (index !== undefined) {
const date = missing[index];
try {
const points = await option.points.fetch(date, controller.signal);
if (isCurrentLoad(option, controller, generation)) {
pointsByDate.set(date, points);
addDateToGrid(date, points);
}
} catch (error) {
if (controller.signal.aborted) return;
failed += 1;
console.error(`Failed to fetch heatmap points for ${date}`, error);
} finally {
if (isCurrentLoad(option, controller, generation)) {
completed += 1;
updateStatus(completed, currentDates.length, failed);
}
}
index = nextMissingIndex();
}
});
void Promise.all(workers).then(() => {
if (isCurrentLoad(option, controller, generation)) {
updateStatus(completed, currentDates.length, failed);
}
});
function nextMissingIndex() {
if (cursor >= missing.length) return undefined;
const index = cursor;
cursor += 1;
return index;
}
}
/**
* @param {HeatmapOption} option
* @param {AbortController} controller
* @param {number} generation
*/
function isCurrentLoad(option, controller, generation) {
return (
currentOption === option &&
abortController === controller &&
loadGeneration === generation &&
!controller.signal.aborted
);
}
function rebuildGrid() {
if (
!currentOption ||
!renderer ||
renderer.width < 1 ||
renderer.height < 1 ||
!currentDates.length
) {
currentGrid = undefined;
return;
}
currentGrid = currentOption.cells.create({
dates: currentDates,
width: renderer.width,
height: renderer.height,
});
for (let i = 0; i < currentDates.length; i++) {
const points = pointsByDate.get(currentDates[i]);
if (points) currentGrid.add(i, points);
}
paint();
}
/**
* @param {string} date
* @param {HeatmapPoints} points
*/
function addDateToGrid(date, points) {
if (!currentGrid) return;
const dateIndex = currentDateIndex.get(date);
if (dateIndex === undefined) return;
const dirtyCol = currentGrid.add(dateIndex, points);
if (dirtyCol !== undefined) paint([dirtyCol]);
}
/** @param {Iterable<number>} [dirty] */
function paint(dirty) {
if (!renderer || !currentGrid || !currentOption) return;
const grid = currentGrid;
const option = currentOption;
renderer.paint(
grid.cols,
grid.rows,
(col, row) =>
option.color(grid.getValue(col, row), { dark, grid, col, row }),
dirty,
);
}
/** @param {MouseEvent} event */
function updateTooltip(event) {
if (!canvas || !currentGrid || !currentOption?.tooltip) return;
const rect = canvas.getBoundingClientRect();
const col = Math.floor(((event.clientX - rect.left) * currentGrid.cols) / rect.width);
const row = Math.floor(((event.clientY - rect.top) * currentGrid.rows) / rect.height);
if (col < 0 || col >= currentGrid.cols || row < 0 || row >= currentGrid.rows) {
canvas.removeAttribute("title");
return;
}
canvas.title = currentOption.tooltip({ grid: currentGrid, col, row });
}
/**
* @param {readonly string[]} dates
* @returns {Map<string, number>}
*/
function createDateIndex(dates) {
const map = new Map();
for (let i = 0; i < dates.length; i++) {
map.set(dates[i], i);
}
return map;
}
/**
* @param {number} completed
* @param {number} total
* @param {number} failed
*/
function updateStatus(completed, total, failed) {
if (!statusElement) return;
if (completed >= total) {
statusElement.textContent = failed ? `${failed} failed` : "";
} else {
statusElement.textContent = failed
? `${completed}/${total} · ${failed} failed`
: `${completed}/${total}`;
}
}
function createRangeControls() {
const fieldset = document.createElement("fieldset");
fieldset.classList.add("heatmap-controls");
statusElement = document.createElement("small");
statusElement.classList.add("heatmap-status");
const currentYear = new Date().getUTCFullYear();
const fromChoices = createFromChoices(currentYear);
const toChoices = createToChoices(currentYear);
let fromChoice = fromChoices[0];
let toChoice = toChoices[0];
const fromSelect = createSelect({
id: "heatmap-from",
choices: fromChoices,
initialValue: fromChoice,
onChange(choice) {
fromChoice = choice;
setRange(fromChoice.date, toChoice.date);
},
toKey: rangeChoiceKey,
toLabel: rangeChoiceLabel,
});
const toSelect = createSelect({
id: "heatmap-to",
choices: toChoices,
initialValue: toChoice,
onChange(choice) {
toChoice = choice;
setRange(fromChoice.date, toChoice.date);
},
toKey: rangeChoiceKey,
toLabel: rangeChoiceLabel,
});
const fromLabel = createControlField("from", fromSelect);
const toLabel = createControlField("to", toSelect);
fieldset.append(fromLabel, toLabel, statusElement);
return fieldset;
}
/**
* @param {number} currentYear
* @returns {RangeChoice[]}
*/
function createFromChoices(currentYear) {
const choices = [{ label: "genesis", date: GENESIS_DATE }];
for (let year = 2009; year <= currentYear; year++) {
choices.push({ label: String(year), date: yearStartISODate(year) });
}
return choices;
}
/**
* @param {number} currentYear
* @returns {RangeChoice[]}
*/
function createToChoices(currentYear) {
const choices = [{ label: "today", date: todayISODate() }];
for (let year = currentYear; year >= 2009; year--) {
choices.push({ label: String(year), date: yearEndISODate(year) });
}
return choices;
}
/**
* @param {RangeChoice} choice
*/
function rangeChoiceKey(choice) {
return choice.label;
}
/**
* @param {RangeChoice} choice
*/
function rangeChoiceLabel(choice) {
return choice.label;
}
/**
* @param {string} text
* @param {HTMLElement} control
*/
function createControlField(text, control) {
const label = document.createElement("div");
label.classList.add("heatmap-control");
const span = document.createElement("span");
span.textContent = text;
const select = control.querySelector("select");
if (select) select.ariaLabel = text;
label.append(span, control);
return label;
}
/**
* @param {string} nextFrom
* @param {string} nextTo
*/
function setRange(nextFrom, nextTo) {
if (nextFrom > nextTo) {
from = nextTo;
to = nextFrom;
} else {
from = nextFrom;
to = nextTo;
}
loadRange();
}
/** @param {number} year */
function yearStartISODate(year) {
return toISODate(new Date(Date.UTC(year, 0, 1)));
}
/** @param {number} year */
function yearEndISODate(year) {
return toISODate(
new Date(
Math.min(
Date.UTC(year, 11, 31),
Date.parse(`${todayISODate()}T00:00:00Z`),
),
),
);
}
+17
View File
@@ -1,3 +1,5 @@
/** @import { HeatmapColorFn } from "./types.js" */
const INFERNO_STOPS = [
[0, 0, 0, 0],
[0.13, 40, 11, 84],
@@ -12,6 +14,21 @@ const INFERNO_STOPS = [
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
/**
* @param {Object} args
* @param {ArrayLike<number>} args.light
* @param {ArrayLike<number>} args.dark
* @returns {HeatmapColorFn}
*/
export function intensityColor({ light, dark }) {
return (value, context) => {
if (!Number.isFinite(value)) return 0x00000000;
const lut = context.dark ? dark : light;
const index = Math.min(255, Math.max(0, Math.round(value * 255)));
return lut[index] ?? 0x00000000;
};
}
/**
* @param {number[][]} stops - Tuples of [position, red, green, blue].
*/
+64 -1
View File
@@ -1,10 +1,73 @@
#heatmap {
height: 100%;
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
padding: var(--main-padding);
background-color: var(--background-color);
> header {
flex-shrink: 0;
display: flex;
white-space: nowrap;
overflow-x: auto;
padding-bottom: 0.25rem;
margin-bottom: -0.25rem;
padding-left: var(--main-padding);
margin-left: var(--negative-main-padding);
padding-right: var(--main-padding);
margin-right: var(--negative-main-padding);
}
> fieldset.heatmap-controls {
flex-shrink: 0;
text-transform: lowercase;
overflow-x: auto;
min-width: 0;
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
margin: 0.5rem var(--negative-main-padding);
padding: 0.5rem var(--main-padding);
gap: 1rem;
.heatmap-control {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
color: var(--color);
span {
color: var(--off-color);
}
> div.field {
display: flex;
align-items: baseline;
flex-shrink: 0;
gap: 0.375rem;
cursor: pointer;
select {
width: auto;
line-height: 1;
}
}
}
.heatmap-status {
color: var(--off-color);
white-space: nowrap;
}
}
> canvas {
flex: 1%;
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
display: block;
image-rendering: pixelated;
}
}
+1
View File
@@ -1,4 +1,5 @@
const DAY_MS = 86_400_000;
export const GENESIS_DATE = "2009-01-03";
/**
* @param {Date} date
+31 -20
View File
@@ -1,27 +1,38 @@
/**
* @typedef {Object} HeatmapDataSource
* @property {(signal: AbortSignal) => Promise<string[]>} list
* @property {(date: string, signal: AbortSignal) => Promise<unknown>} fetch
* @typedef {Object} HeatmapImplicitPoints
* @property {"implicit"} kind
* @property {number} yStart
* @property {number} yStep
* @property {ArrayLike<number>} values
*
* @typedef {Object} HeatmapExplicitPoints
* @property {"explicit"} kind
* @property {ArrayLike<number>} y
* @property {ArrayLike<number>} values
*
* @typedef {HeatmapImplicitPoints | HeatmapExplicitPoints} HeatmapPoints
*
* @typedef {Object} HeatmapPointSource
* @property {(date: string, signal: AbortSignal) => Promise<HeatmapPoints>} fetch
*
* @typedef {Object} HeatmapRange
* @property {number} start
* @property {number} end
*
* @typedef {Object} HeatmapGrid
* @property {readonly string[]} dates
* @property {number} cols
* @property {number} rows
* @property {(dateIndex: number, points: HeatmapPoints) => number | undefined} add
* @property {(col: number, row: number) => number} getValue
* @property {(col: number) => HeatmapRange} getDateIndexRange
* @property {(row: number) => HeatmapRange} getYRange
*
* @typedef {Object} HeatmapCells
* @property {(args: { dates: string[], width: number, height: number }) => unknown} create
* @property {(grid: unknown, dateIndex: number, snapshot: unknown) => number | undefined} add
* @property {(grid: unknown, col: number, row: number) => unknown} getValue
* @property {(args: { dates: readonly string[], width: number, height: number }) => HeatmapGrid} create
*
* @typedef {Object} HeatmapColorContext
* @property {boolean} dark
* @property {unknown} grid
* @property {number} col
* @property {number} row
*
* @typedef {(value: unknown, context: HeatmapColorContext) => number} HeatmapColorFn
*
* @typedef {Object} HeatmapTooltipContext
* @property {unknown} grid
* @property {number} col
* @property {number} row
*
* @typedef {(context: HeatmapTooltipContext) => string} HeatmapTooltipFn
* @typedef {(value: number, context: { dark: boolean, grid: HeatmapGrid, col: number, row: number }) => number} HeatmapColorFn
* @typedef {(context: { grid: HeatmapGrid, col: number, row: number }) => string} HeatmapTooltipFn
*/
export {};